# Tinylib developer docs > A Tinylib app is a web page you publish with one command. Tinylib signs users in, syncs their data, and runs the rooms they share, so there's no server to set up. Apps open on the web and in Tinylib's Android app. These docs describe tinylib-sdk 0.44.0. Start every app with `npx tinylib-sdk init`, which writes a working folder from a template. Run it with `npx tinylib-sdk dev` and publish it with `npx tinylib-sdk publish`. Read [Quickstart](https://tinylib.app/docs/start/quickstart.md) and [How Tinylib works](https://tinylib.app/docs/start/how-it-works.md) first. Then read the pages for the app you have in mind: - one person's app whose data syncs across their devices: [Data](https://tinylib.app/docs/guide/data.md) and [tinylib.data](https://tinylib.app/docs/api/data.md) - people sharing live state, such as a game or a shared list: [Rooms and rules](https://tinylib.app/docs/guide/rooms.md), [Kinds](https://tinylib.app/docs/rules/kinds.md), and [ctx](https://tinylib.app/docs/rules/ctx.md) - a turn-based game with friends, or a daily puzzle: [Games](https://tinylib.app/docs/guide/games.md) and [tinylib-sdk/kits](https://tinylib.app/docs/api/kits.md) An app must: - offer Block and Report, with `tinylib.block` and `tinylib.report`, wherever it shows another user, as the [Developer Terms](https://tinylib.app/developer-terms) require - never ask for payment, which the Developer Terms forbid - ship every file it uses in its folder, because the page can't fetch other sites; `publish` bundles imported libraries - in `rules.js`, keep everything in the room's state and use `ctx.now` and `ctx.random()` instead of `Date.now()` and `Math.random()` - import only types from `tinylib-sdk` in `rules.js` - write every rules handler synchronously, with no `async` or `await`, which `dev` and `publish` refuse - declare a key in `rulesKeys` before the client ever writes it: a new version can't declare a key the client has written, or remove a declared one - keep secrets out of shipped files and addresses, which anyone can read ## Quickstart > Make an app, run it on your computer, and open it on your phone. Source: https://tinylib.app/docs/start/quickstart.md A Tinylib app is a web page, and Tinylib runs everything behind it, so there's no server to set up. ### Create an app You need Node.js 22 or newer. ```sh npx tinylib-sdk init my-app ``` `init` asks for a template, then the app's name, slug, and description. | Template | What it makes | | --- | --- | | `solo`, the default | A tap counter for one person, saved on all their devices with `tinylib.data` | | `multiplayer` | A tap counter people share in a room, where `rules.js` counts the taps on Tinylib's servers | | `game` | Tic-tac-toe for two friends, with turns, invitations, and rematches | > The slug is your app's address, as in `tinylib.app/my-app`, and it can't change after you first publish. - `my-app/` - `tinylib.json`: Your app's name, slug, description, and icon. See [tinylib.json](https://tinylib.app/docs/reference/tinylib-json.md). - `index.html`: The page your app opens on. - `app.js`: The page's script. See [tinylib](https://tinylib.app/docs/api/tinylib.md). - `style.css` - `icon.png`: Replace it with a square PNG at least 512 pixels on a side. - `rules.js`: In `multiplayer` and `game`: the code that runs its rooms on Tinylib's servers. See [Rooms and rules](https://tinylib.app/docs/guide/rooms.md). - `package.json`: Has `npm run dev` and `npm run publish`. ### Run it on your computer ```sh cd my-app npm install npx tinylib-sdk dev ``` `dev` opens your app twice, side by side, as two pretend users, Ada and Ben. This is the `solo` template's whole `app.js`: ```js app.js /// const greeting = document.getElementById('greeting') const tap = document.getElementById('tap') const taps = () => tinylib.data.get('taps') ?? 0 function draw() { greeting.textContent = `Hello, ${tinylib.user.name}!` tap.textContent = `Tapped ${taps()} ${taps() === 1 ? 'time' : 'times'}` } tap.onclick = () => { tinylib.data.set('taps', taps() + 1) draw() } tinylib.data.onChange(draw) tinylib.user.onChange(draw) draw() ``` `tinylib.data` keeps the count on the device, so reading it is instant and works offline, and Tinylib syncs it to the user's other devices. `onChange` redraws when a change arrives from one of them. Edit the greeting and save, and both panes reload. ### Publish ```sh npx tinylib-sdk publish ``` The first time, it opens Tinylib in your browser to sign you in. Then it prints your app's link: ```text ✓ Published My app as version 1. https://tinylib.app/my-app ``` Open the link on your phone. Anyone with the link can open your app, and running `publish` again updates it for all of them. ### Next steps - [How Tinylib works](https://tinylib.app/docs/start/how-it-works.md): where your app runs, and what Tinylib runs for it. - [Data](https://tinylib.app/docs/guide/data.md): save the user's data on all their devices. - [Rooms and rules](https://tinylib.app/docs/guide/rooms.md): let people play or work together live. - [Games](https://tinylib.app/docs/guide/games.md): build a daily puzzle or a game with friends. - [Reminders and notifications](https://tinylib.app/docs/guide/reminders.md): remind the user, or notify a room's members. - [Publishing](https://tinylib.app/docs/guide/publishing.md): use TypeScript or a bundler, test with a preview, and get listed. ## How Tinylib works > What you write for a Tinylib app, and what Tinylib runs for it. Source: https://tinylib.app/docs/start/how-it-works.md A Tinylib app is a web page. People open it at its link, like `tinylib.app/tic-tac-toe`, in a browser or in Tinylib's Android app, and your page reaches everything Tinylib runs for it through a `tinylib` object. You write the page, plus rules if people share live state. Your client runs in a sandboxed frame in Tinylib on the user's device, beside a copy of their data and reminders. Your rules run each room on Tinylib's servers, which also sync the data and send notifications. ### Where your app runs Anyone can use your app, with or without a Tinylib account. `tinylib.user` gives your app their name and id either way, and `tinylib.user.guest` says whether they have an account. An account is what lets Tinylib reach a user while your app is closed and connect them with other people: notifications, friends, and invitations need one, and it brings the user's apps and data to every device they sign in on. A user without one is a [guest](https://tinylib.app/docs/guide/rooms.md#guests), whose apps, data, and rooms stay on the device they use until they sign in. What Tinylib can do around your app, like notifications and opening offline, differs by device, and [Platforms](https://tinylib.app/docs/guide/platforms.md) lists it. ### What you write #### The client Write the client as an ordinary web page, `index.html` with its scripts and styles, plain or with any framework. `publish` bundles each script with the npm packages it imports. Before your scripts run, Tinylib puts the [`tinylib`](https://tinylib.app/docs/api/tinylib.md) object on the page with the user and their data already loaded, so reading them is synchronous and works offline: ```js app.js const todos = tinylib.data.values('todo/') document.querySelector('h1').textContent = `${tinylib.user.name}, ${todos.length} to do` ``` #### The rules Anything people share live, like one game or one shared list, is a **room** on Tinylib's servers, with its state, its members, and the rules that run it. The rules are server code you write in `rules.js`. Each export of `rules.js` with a `create` function is a **kind** of room, like `game` below. `create` returns a new room's state. When a member acts, an action handler changes the state or refuses with a sentence your client can show. After each change, `view` decides what each member sees. Here `canInvite` lets in a second player and no one after: ```js rules.js export const game = { create(options, ctx) { return { board: Array(9).fill(''), players: { x: ctx.user.id, o: null }, turn: 'x', winner: null } }, canInvite(state) { return state.players.o === null }, join(state, via, ctx) { state.players.o = ctx.user.id }, actions: { move(state, square, ctx) { if (state.players[state.turn] !== ctx.user.id) ctx.refuse("It's not your turn.") if (!Number.isInteger(square) || state.board[square] !== '') ctx.refuse('Pick an empty square.') state.board[square] = state.turn // ...on a win or a draw, set state.winner and call ctx.end(); otherwise pass the turn }, }, view(state, member) { const you = member.id === state.players.x ? 'x' : member.id === state.players.o ? 'o' : null return { board: state.board, turn: state.turn, winner: state.winner, you } }, } ``` The client creates a room, draws each view, and invites the other player: ```js app.js try { const { id } = await tinylib.rooms.create('game') tinylib.rooms.follow(id, ({ view, error }) => { if (error) toast(error.message) else if (view) drawBoard(view) }) await tinylib.rooms.invite(id) } catch (error) { toast(error.message) } ``` `follow` also passes the open `room`, and a tap on a square calls `room.act('move', square)`, which runs `move` in the rules. ### What Tinylib runs - Tinylib syncs [the user's data](https://tinylib.app/docs/guide/data.md) across their devices. Only your rules can write the keys listed in `rulesKeys` in `tinylib.json`, so a device can't fake them. - Tinylib keeps each [room's](https://tinylib.app/docs/guide/rooms.md) state, runs your rules on each action, join, leave, and timer in turn, and sends each member their view. A room needs a connection unless its kind is marked `offline`. - A user's [friends](https://tinylib.app/docs/guide/friends.md) are the same in every app. An app that uses friends can list the ones who have it, read what they save for friends, and offer to send them the app. - Your rules can [notify](https://tinylib.app/docs/guide/reminders.md) a room's members, and your client can schedule reminders for the user. Tinylib delivers both, and the user can turn either off for your app. - Each screen can have an [address](https://tinylib.app/docs/guide/places.md) after your app's link, like `tinylib.app/tic-tac-toe/game/abc`, which a shared link or a notification opens. - `npx tinylib-sdk publish` uploads your files and rules as a new [version](https://tinylib.app/docs/guide/publishing.md) and makes it current for everyone at once, after Tinylib's review if your app is listed. Clients open in a room reload onto it, any other client uses it at its next open, and open rooms keep their state under the new rules. #### Tinylib's sheets For anything that involves other people or the user's settings, Tinylib draws a sheet over your app. Your client opens one with a call but can't see inside it, fake it, or press it for the user: | Your client calls | Tinylib shows | Your client learns | | --- | --- | --- | | `tinylib.rooms.invite(id)` | The invite sheet, with the user's friends and the room's link | That the sheet closed | | `tinylib.friends.sendApp()` | The send sheet, with the user's friends | That the sheet closed | | `tinylib.places.share(address)` | The device's share sheet with a link to that place, or Tinylib's own where the device has none | That the sheet closed | | `tinylib.user.manageSettings()` | The settings sheet, with your app's switches | The switches' values | | `tinylib.block(id)` or `tinylib.report(id)` | The block sheet or the report sheet | Whether the user went through with it | ### What this rules out - Your client loads files only from its own origin and talks only to Tinylib. Scripts, fonts, and images from a CDN don't load, and calls to another site's API fail, so everything it uses ships in the folder you publish. - The rules have no `fetch`, and their handlers are synchronous. They run only when something happens in a room. To act later, a room sets a timer. - Tinylib runs no code of yours outside rooms: no endpoints, webhooks, or scheduled jobs. - Tinylib stores JSON values: the user's data, up to 1 MB, and each room's state and room data. It has nowhere to keep photos or other files people add. - You get no view of any user's data or any room's state. [The developer page](https://tinylib.app/developer) shows your rules' errors. - Your app never sees a user's email address or contacts. Their id differs in every app, so you can't match them across apps or to another service. - The [Developer Terms](https://tinylib.app/developer-terms) don't let your app ask for payment. The Developer Terms also require your app to suit people 16 and over. An app that shows people to each other must [let them block and report each other](https://tinylib.app/docs/guide/friends.md#let-users-block-and-report) through Tinylib. ## Data > Save the user's data and sync it across their devices. Source: https://tinylib.app/docs/guide/data.md `tinylib.data` saves the user's data for your app as keys with JSON values. Every call is synchronous and works offline, and Tinylib syncs the data across the user's devices. Each of the user's devices keeps a copy of their data, which your client reads and writes and Tinylib syncs with its servers. Your rules write the keys declared for them, and a friend's client reads the keys saved for friends. ### How the user's data syncs A `set` or `remove` changes the device's copy at once, and Tinylib sends the write to its servers moments later. Offline, the write waits on the device until there's a connection. Nothing tells your client when a write has reached the servers. When two devices change the same key, the write that reaches Tinylib's servers last wins, whenever it was made. A `remove` is a write like any other. If the user ticks off a to-do on their phone while offline, then removes it on their laptop, the to-do comes back when the phone reconnects. The device whose write lost gets the winning value, and [`onChange`](https://tinylib.app/docs/guide/data.md#react-to-changes-from-elsewhere) tells its client. In `npx tinylib-sdk dev`, choose the same user at the top of both panes to watch a change reach another device. To make a conflict, turn one pane's Online switch off, change the same key in both panes, then turn it back on. A [guest](https://tinylib.app/docs/guide/rooms.md#guests), a user without a Tinylib account, has data like anyone, and it reaches no other device until they sign in. ### Who can read and write it | Who | Reads | Writes | | --- | --- | --- | | Your client, on the user's devices | Every key | Every key except [declared ones](https://tinylib.app/docs/guide/data.md#keys-only-the-rules-write) | | Your rules, in a room the user is in | Declared keys | Declared keys | | Your client, on a friend's device | Keys saved for friends, while online | Nothing | | Other apps, and you as the developer | Nothing | Nothing | A value is private to the user unless it's saved with `audience: 'friends'`. A later save without `audience` keeps the value's audience, and a save with `audience: 'me'` makes it private again. For data that several people change together, like a shopping list a couple shares, use a [room](https://tinylib.app/docs/guide/rooms.md). The user can delete their data in your app from Tinylib. That deletes everything saved for them in your app, takes them out of its rooms, and closes your app without warning if it's open. If they come back, they start as a new user with a new id. ### Save and read values `set` saves a value under a string key, `get` reads it, and `remove` deletes it: ```js tinylib.data.set('entry/2026-10-09', 'Rain all day') tinylib.data.get('entry/2026-10-09') // 'Rain all day' tinylib.data.remove('entry/2026-10-01') ``` `set` stores the value as `JSON.stringify` writes it, so a `Date` comes back as a string. A value JSON can't hold, like `undefined`, throws a `TypeError`. `get` returns a new copy each time, so changing what it returns changes nothing until you `set` it. To read related keys together, give them a common prefix. `keys`, `values`, and `entries` return the keys that start with it, sorted by key, so dates written as `YYYY-MM-DD` come back in date order: ```js tinylib.data.keys('entry/') // ['entry/2026-10-08', 'entry/2026-10-09'] tinylib.data.entries('entry/') // [['entry/2026-10-08', '…'], ['entry/2026-10-09', 'Rain all day']] ``` There's no query call. The whole store is in memory, so filter what `entries` returns. ### Decide what goes in one key A write replaces a key's whole value, and conflicts are settled key by key. Give each item in a list its own key, so edits to two items on two devices both survive: ```js const id = crypto.randomUUID() tinylib.data.set(`todo/${id}`, { text: 'Buy milk', done: false, createdAt: tinylib.now() }) ``` To list them in order, keep each item's creation time in its value and sort by it: ```js const todos = tinylib.data.values('todo/').sort((a, b) => a.createdAt - b.createdAt) ``` Tinylib has no transaction across keys, though, so keep values that must change together in one key, like a puzzle's grid and its move count. Two devices that edit that key conflict over all of it. ### React to changes from elsewhere Your client opens on the device's copy of the data, and what changed elsewhere arrives just after. To redraw when the user's data changes on another device, in another tab, or in your rules, pass a function to `onChange`. It gets the keys that changed, and it isn't called for this client's own writes: ```js app.js tinylib.data.onChange((keys, error) => { if (error) toast(error.message) if (keys.some((key) => key.startsWith('entry/'))) drawEntries() }) ``` `onChange` also reports a write that failed after `set` returned, for example because writes from another device took the data past 1 MB. Then `error` is set and those keys go back to what Tinylib's servers have, so the user's change is gone from the device. Keep what the user typed on screen so they can try again. [`onChange`](https://tinylib.app/docs/api/data.md#onchange) lists each `error.code`. ### Handle a full store A `set` that would grow the data your client writes for the user past 1 MB throws a `TinylibError` with the code `limit` and saves nothing. A `set` that makes the data smaller still works, and so does `remove`: ```js app.js try { tinylib.data.set('entry/2026-10-09', document.querySelector('#entry').value) } catch (error) { if (error.code !== 'limit') throw error toast(error.message) } ``` ### Keys only the rules write A user can make their own client save anything. For a value other users should trust, like a count of wins, declare its key in `rulesKeys` in `tinylib.json`, and only your rules can write it. A name ending in `/` declares every key under it: ```json tinylib.json { "rulesKeys": ["wins", "rating", "games/"] } ``` Your rules write a declared key for a room member with `ctx.data(id)`. Use `add`, `min`, or `max` rather than `get` then `set`: if two rooms finish at the same moment, `add` counts both, while `get` then `set` drops one. ```js rules.js export const game = { // ... actions: { move(state, square, ctx) { // ...place the mark, and on a win set state.winner to the mark if (state.winner === state.turn) { ctx.data(ctx.user.id).add('wins', 1, { audience: 'friends' }) ctx.end() } }, }, } ``` Your client reads a declared key like any other, but its `set` and `remove` throw a `TypeError` for one. > A key stays declared for the app's life: no later [version](https://tinylib.app/docs/guide/publishing.md) can drop it from `rulesKeys`. A new version also can't declare a key that already holds a value your client wrote for any user. ### Type your data In TypeScript, declare your keys on [`Register`](https://tinylib.app/docs/api/types.md#register) in a `.d.ts` file: ```ts tinylib-env.d.ts import type {} from 'tinylib-sdk' declare module 'tinylib-sdk' { interface Register { data: { wins: number; [key: `entry/${string}`]: string } } } ``` `get` and `set` then check each key's type, while `entries` and `values` still return `unknown`. Tinylib checks nothing at runtime, and values an earlier version wrote may have [another shape](https://tinylib.app/docs/guide/publishing.md#read-data-that-earlier-versions-wrote). ## Rooms and rules > Let users share live state in a room, run by rules you write. Source: https://tinylib.app/docs/guide/rooms.md 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. Ben creates a room and Ada joins it. Each action runs a handler in your rules, which refuses it or changes the state, and each member gets only their own view of the state. The examples build tic-tac-toe. For a turn-based game with friends, [`tinylib-sdk/kits`](https://tinylib.app/docs/guide/games.md) 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: ```js rules.js 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 } }, // ... } ``` ```js app.js const { id } = await tinylib.rooms.create('game') ``` `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](https://tinylib.app/docs/guide/places.md#where-users-arrive-from-outside). ### Open a room `tinylib.rooms.follow` opens a room, calls your function with each new view, and returns a function that closes it: ```js 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](https://tinylib.app/docs/guide/rooms.md#offline-kinds). 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`](https://tinylib.app/docs/api/rooms.md) 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: ```js 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: ```js 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`](https://tinylib.app/docs/rules/kinds.md#joinvia) says how: ```js 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](https://tinylib.app/docs/guide/friends.md#let-users-block-and-report). ### 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: ```js 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: ```js 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](https://tinylib.app/developer). 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. ```js rules.js 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 } }, } ``` ```js rules.js export const cards = { // ... view(state, member) { return { hand: state.hands[member.id], pile: state.pile.length } }, } ``` `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`. ```js 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: ```js 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: ```js 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](https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking) and [Gabriel Gambetta's entity interpolation](https://www.gabrielgambetta.com/entity-interpolation.html) do. A relay late by less than that delay is never seen: ```js 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](https://tinylib.app/docs/guide/rooms.md#run-the-rules-on-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: ```js 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](https://tinylib.app/docs/guide/rooms.md#act-later-with-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](https://tinylib.app/docs/reference/limits.md#rooms), 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: ```js 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](https://tinylib.app/docs/guide/reminders.md#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](https://tinylib.app/docs/guide/rooms.md#keep-data-with-a-room). 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: ```js 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`](https://tinylib.app/docs/guide/games.md#list-the-user-s-games) 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: ```js 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: ```js 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`](https://tinylib.app/docs/api/invitations.md#handle) can offer them on the finished game's screen instead of in Tinylib's sheet. In a turn-based game, [`playAgain`](https://tinylib.app/docs/guide/games.md#offer-a-rematch) 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](https://tinylib.app/docs/rules/kinds.md#kind) lists every handler and when it runs, and [ctx](https://tinylib.app/docs/rules/ctx.md) 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: ```js rules.js 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 }, }, } ``` ```js app.js const milk = { id: crypto.randomUUID(), text: 'Milk' } await room.act('add', milk) await room.act('check', { id: milk.id, done: true }) ``` 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: ```js 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: ```js 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. ```js 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](https://tinylib.app/docs/guide/data.md#keys-only-the-rules-write) 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: | Call | For a guest | | --- | --- | | `tinylib.data`; `rooms.create`, `open`, `follow`, `join`, `leave`, `getLink`, `list`, and `data`; `room.act`; `reminders.set`, `list`, and `cancel`; and `block` | Work as for anyone. A reminder goes off on the device that set it. | | `rooms.create` and `join` for a kind with `guests: false` | 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` | 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 | Opens the invite sheet with the room's link, and no friends to pick. | | `invitations.list()` | Resolves with `[]`. | | `user.manageSettings()` | 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`: ```js 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: ```js 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`: ```js 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: ```js 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()`: ```js 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. ```js rules.js 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' } }, }, } ``` ```js app.js try { const final = await tinylib.rooms.data(id).get('final') drawFinal(final) } catch (error) { toast(error.message) } ``` A member's record across rooms, like their wins, belongs in their own data, under [keys only the rules write](https://tinylib.app/docs/guide/data.md#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](https://tinylib.app/docs/guide/publishing.md#change-the-rules-of-a-live-app) covers `migrate` and the check Tinylib runs on open rooms before a version goes live. ## Games > Build a daily puzzle or a turn-based game with friends, using tinylib-sdk/kits. Source: https://tinylib.app/docs/guide/games.md `tinylib-sdk/kits` has what games share: a daily puzzle that every user plays on the same day and compares with friends, a solo game with practice modes, a turn game defined in one place, and pages for games with friends. ### Import the helpers Apps made with `npx tinylib-sdk init` already have the package. Otherwise, install it: ```sh npm install --save-dev tinylib-sdk ``` ```js app.js import { clock, countdown, daily, followDailyReminder, leaderboard, seeded, shuffled, stopwatch, today } from 'tinylib-sdk/kits' ``` `dev` and `publish` add the [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses) entries the helpers need, like `"friends"` for `daily`. Your rules can import `tinylib-sdk/kits` too, for what needs no `tinylib`: [`defineGame`](https://tinylib.app/docs/api/kits.md#definegame), the seats, the words, `addDays`, `seeded`, `shuffled`, `clock`, and `today` given a date. `publish` bundles what you import into the version, so a version keeps the kit it was published with. ### Build a daily puzzle The example is a daily hard sudoku. #### Deal the day's puzzle Deal the puzzle from the date instead of from chance. [`today()`](https://tinylib.app/docs/api/kits.md#today) returns today's date, like `'2026-10-20'`, and [`seeded(seed)`](https://tinylib.app/docs/api/kits.md#seeded) returns a random number generator that gives the same numbers for the same seed: ```js app.js const day = today() const random = seeded(`hard/${day}`) let grid = makeSudoku(random) // your generator, drawing every choice from random ``` Each user gets the new puzzle at their own midnight, so a friend in another time zone may be a day ahead. [`shuffled(tiles, random)`](https://tinylib.app/docs/api/kits.md#shuffled) deals tiles or cards the same way for everyone. #### Time the user [`stopwatch(onTick)`](https://tinylib.app/docs/api/kits.md#stopwatch) calls `onTick` every second with the seconds spent, and pauses while the page is hidden. [`clock`](https://tinylib.app/docs/api/kits.md#clock) writes seconds as `'1:23'`. Save the grid and time after each move, so a user who closes the app can carry on: ```js app.js const watch = stopwatch((seconds) => { timer.textContent = clock(seconds) }) const saved = tinylib.data.get('progress/hard') if (saved?.day === day) { grid = saved.grid watch.seconds = saved.seconds } watch.start() // After each move: tinylib.data.set('progress/hard', { day, grid, seconds: watch.seconds }) ``` #### Save the result [`daily(name)`](https://tinylib.app/docs/api/kits.md#daily) holds the user's results for one puzzle, one per date. An app with an easy and a hard puzzle uses `daily('easy')` and `daily('hard')`, each with its own streak and leaderboard. [`save(result, day)`](https://tinylib.app/docs/api/kits.md#puzzle-save) stores the result where friends can read it: ```js app.js const hard = daily('hard') // Once the puzzle is solved: watch.pause() hard.save({ seconds: Math.round(watch.seconds) }, day) ``` Pass the day the puzzle was dealt for, or a puzzle finished after midnight counts for the next day. Only the first result for a day is kept; a later `save` returns false. [`get(day)`](https://tinylib.app/docs/api/kits.md#puzzle-get) reads it back: ```js app.js const result = hard.get(day) if (result) showResult(result) else showPuzzle(grid) ``` Results come from the user's device, so a user can fake one. A value users can trust belongs in a [key only the rules write](https://tinylib.app/docs/guide/data.md#keys-only-the-rules-write). #### Count down to tomorrow [`countdown(fn)`](https://tinylib.app/docs/api/kits.md#countdown) calls `fn` every second with the milliseconds left until the user's midnight, then with 0 when the date changes: ```js app.js const stopCountdown = countdown((left) => { if (left) nextPuzzle.textContent = `New puzzle in ${clock(Math.ceil(left / 1000))}` else showToday() }) ``` #### Show a streak [`streak()`](https://tinylib.app/docs/api/kits.md#puzzle-streak) counts the days in a row with a saved result: ```js app.js streakLabel.textContent = `${hard.streak()}-day streak` ``` If a failed day should break the streak, save only solved puzzles. #### Show friends' results [`leaderboard(key, compare, options)`](https://tinylib.app/docs/api/kits.md#leaderboard) resolves with the user's and their friends' results for the day's [`key`](https://tinylib.app/docs/api/kits.md#puzzle-key), best first by `compare`. `valid` drops results an older version of your app saved in another shape: ```js app.js try { const rows = await leaderboard(hard.key(day), (a, b) => a.seconds - b.seconds, { valid: (value) => typeof value?.seconds === 'number', }) for (const row of rows) renderRow(row.you ? 'You' : row.name, clock(row.value.seconds)) } catch (error) { if (error.code !== 'offline') throw error showOffline() } ``` For friends' results without the ranking, use [`friendsWith(key)`](https://tinylib.app/docs/api/kits.md#friendswith). #### Remind the user every day [`followDailyReminder(name, options, fn)`](https://tinylib.app/docs/api/kits.md#followdailyreminder) keeps a toggle on your page in step with a daily reminder. It calls `fn` now and whenever the reminder changes: ```js app.js const reminder = followDailyReminder('sudoku', { title: 'Sudoku', text: "Today's puzzle is ready." }, ({ available, time, muted }) => { reminderRow.hidden = !available reminderToggle.checked = time !== null remindersOff.hidden = !muted }) reminderToggle.onchange = async () => { try { await reminder.set(reminderToggle.checked ? '09:00' : null) } catch (error) { toast(error.message) } } ``` `available` is false offline. If `set` fails, `fn` gets the reminder as it was, so the toggle flips back. #### Try the puzzle in dev In `npx tinylib-sdk dev`, the simulated users are all friends, so solving the puzzle in two panes puts both on the leaderboard. `today()` and `countdown` follow `dev`'s clock, so skipping it past midnight brings the next day's puzzle. ### Build a solo game [`solo(name)`](https://tinylib.app/docs/api/kits.md#solo) keeps a puzzle's games, one for [today](https://tinylib.app/docs/api/kits.md#today-mode) and one for each practice mode, and the best score in each. Its `results` are `daily(name)`: ```js app.js import { clock, friendsToday, reminderSwitch, solo, today, TODAY } from 'tinylib-sdk/kits' const games = solo('sudoku') // After each move: games.keep(TODAY, { grid, seconds }) // Once the puzzle is solved: games.results.save({ seconds }, today()) games.clear(TODAY) const { record } = games.record('hard', seconds) ``` `saved(mode)` reads a game back. Today's counts only on its day, so tomorrow it returns null and you deal the new puzzle. Lower scores win unless you give `solo` a `better` function. [`friendsToday(key, options)`](https://tinylib.app/docs/api/kits.md#friendstoday) makes the list of friends' results, and [`reminderSwitch(options)`](https://tinylib.app/docs/api/kits.md#reminderswitch) makes the daily reminder's switch. Each returns its `node` to place and a `stop` to call when the screen closes: ```js app.js const friends = friendsToday(games.results.key(today()), { compare: (a, b) => a.seconds - b.seconds, show: (result) => clock(result.seconds), className: 'friends', }) const reminder = reminderSwitch({ title: 'Sudoku', text: "Today's puzzle is ready." }) screen.append(friends.node, reminder.node) ``` ### Define a turn game [`defineGame(spec)`](https://tinylib.app/docs/api/kits.md#definegame) compiles a game to a [kind](https://tinylib.app/docs/guide/rooms.md#create-a-room). You say what each player may do. The kit runs the seats, the turns, the clock, players who leave, and the end of the game, and tells the page what's legal. #### Write the game Here is tic-tac-toe, whole: ```js rules.js import { defineGame } from 'tinylib-sdk/kits' 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]] const won = (board, color) => LINES.some((line) => line.every((at) => board[at] === color)) export const game = defineGame({ seats: { min: 2, max: 2 }, turn: { limit: null }, prepare(state) { state.table.board = Array(9).fill(null) }, moves: { mark: { options: ({ table }) => table.board.flatMap((cell, at) => (cell === null ? [at] : [])), run: ({ table }, seat, at) => void (table.board[at] = seat.color), }, }, over({ table }, { by, canMove }) { if (by && won(table.board, by.color)) return { winners: [by.id] } return canMove ? null : { winners: [] } }, }) ``` A move lists what a player may send in `options`, and anything else is refused. A move too open to list has `check` instead, which returns a sentence to refuse. `run` makes the move. It may return what happened, as words after the player's name, and everyone reads it as "You marked a square." or "Ada marked a square." Wins count under `wins`, so add it to [`rulesKeys`](https://tinylib.app/docs/reference/tinylib-json.md#ruleskeys): ```json tinylib.json { "rulesKeys": ["wins"] } ``` #### Keep secrets A game keeps its state in three places: `state.table`, which everyone sees, `state.hidden`, which nobody sees, and `seat.private`, which only that player sees. `show` says what may be known of the hidden parts: ```js rules.js import { defineGame, shuffled } from 'tinylib-sdk/kits' export const game = defineGame({ // ... setup(state, ctx) { state.hidden.deck = shuffled(newDeck(), ctx.random) for (const seat of state.seats) seat.private = { hand: state.hidden.deck.splice(0, 5) } }, show: { table: (state) => ({ deck: state.hidden.deck.length }), seat: (seat) => ({ cards: seat.private.hand.length }), }, }) ``` #### Set a clock `turn.limit` is how long a turn may take. When it runs out, the kit plays the first option of the first move in `timeout` that has one, or passes the turn: ```js rules.js turn: { limit: 60_000, timeout: ['draw'], warn: 15_000 }, ``` A player who runs out of time three times in a row is out, and `strikes` changes that. `warn` reminds them first. #### Handle players who leave A game with enough players left plays on, and `left` tidies up after the player who went, such as cards back in the deck. Otherwise the game goes to the rest, or ends with no winner if the player who went never moved. [`leaveWarning(view)`](https://tinylib.app/docs/api/kits.md#leavewarning) words what leaving would do, for your page to ask first. #### Draw it [`gamePages(address)`](https://tinylib.app/docs/api/kits.md#gamepages) follows a game and says what to draw. Exactly one of `view`, `gone`, and `message` is set. `view.legal` lists what the user may do now, by move, so the page needs no rules of its own: ```js app.js import { gamePages, route, show } from 'tinylib-sdk/kits' const pages = gamePages('game/:id') function showGame(id) { return pages.follow(id, ({ view, room, gone, message, again }) => { Object.assign(againButton, again) if (!view) return showMessage(gone?.text ?? message) renderBoard(view.table.board) squares.forEach((square, at) => { square.disabled = !view.legal.mark?.includes(at) square.onclick = () => room.act('mark', at).catch(show) }) status.textContent = view.status }) } route({ game: showGame }, { keep: true }) ``` `again` holds the rematch button's text, whether it shows, and what a tap does. It shows once the game ends. It invites the players who have an account, and with nobody to invite it says "New game". When a game ends, the kit keeps the final view in the room's data as `final`, and `follow` shows it. #### Share seats without defineGame For a room that isn't a turn game, [`seats(ctx)`](https://tinylib.app/docs/api/kits.md#seats) keeps who's playing, each in a color of their own, who has the room open, and the host. Spread it into `create`, then call [`sit`](https://tinylib.app/docs/api/kits.md#sit) in `join`, [`stand`](https://tinylib.app/docs/api/kits.md#stand) in `leave`, and [`present`](https://tinylib.app/docs/api/kits.md#present) in `presence`. ### Play with no signal [`localRoom(kind, config)`](https://tinylib.app/docs/api/kits.md#localroom) runs your rules on the device, unchanged, so a game against bots works offline. A bot is another player that sends moves through its own port. Nothing it keeps is saved, and notifications go nowhere: ```js app.js import { localRoom, pausingClock } from 'tinylib-sdk/kits' import { game } from './rules.js' const clock = pausingClock() const room = localRoom(game, { players: [{ id: 'you', name: 'You' }, { id: 'bot', name: 'Bot' }], now: clock.now }) const you = room.port('you') you.onView(draw) setInterval(room.tick, 100) ``` Importing your rules puts them on the device, so keep anything secret out of them. A port has the `act`, `relay`, `onEvent`, and `onRelay` a page gets from `tinylib.rooms`, so the code that sends moves works on both. [`pausingClock()`](https://tinylib.app/docs/api/kits.md#pausingclock) stops the game while the page is hidden, and the room's times are on that clock, so count a `deadline` down with `clock.now()`. [`needsConnection(elements)`](https://tinylib.app/docs/api/kits.md#needsconnection) turns the entries that need a connection off without one. ### Build a game with friends To start from a working game, run `npx tinylib-sdk init my-game --template game`, which makes tic-tac-toe for two friends with turns, invitations, and rematches. These sections build four in a row by hand, on rooms, for a game that doesn't fit `defineGame`. Each game is a [room](https://tinylib.app/docs/guide/rooms.md). #### Write the rules In `rules.js`, the game is a [kind](https://tinylib.app/docs/guide/rooms.md#create-a-room). This excerpt has the parts the helpers rely on: ```js rules.js function finish(state, ctx) { ctx.data.room.set('final', { board: state.board, winner: state.winner }) ctx.setTimer('close', ctx.now + 24 * 60 * 60 * 1000) } export const fourInARow = { address: 'game/:id', create(options, ctx) { return { board: Array(42).fill(''), players: { red: ctx.user.id, yellow: null }, turn: 'red', winner: null, round: 1 } }, canInvite(state) { return state.players.yellow === null }, join(state, via, ctx) { state.players.yellow = ctx.user.id }, actions: { drop(state, column, ctx) { if (state.players.yellow === null) ctx.refuse('Wait for a friend to join.') if (state.players[state.turn] !== ctx.user.id) ctx.refuse("It's not your turn.") // ... refuse a column that's full or doesn't exist, and drop the disc // ... set state.winner to the color on four in a row, or to 'draw' on a full board const next = state.turn === 'red' ? 'yellow' : 'red' if (state.winner) { const text = state.winner === 'draw' ? "It's a draw." : `${ctx.user.name} won.` ctx.notify(state.players[next], { title: 'Game over', text }) finish(state, ctx) } else { state.turn = next ctx.notify(state.players[next], { title: 'Your turn', text: `${ctx.user.name} played.` }) } }, }, view(state, member) { const you = ['red', 'yellow'].find((color) => state.players[color] === member.id) ?? null return { board: state.board, turn: state.turn, winner: state.winner, started: state.players.yellow !== null, you, round: state.round } }, 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.yellow === null) return { text: 'Waiting for a friend', waiting: false } return state.players[state.turn] === member.id ? { text: 'Your turn', waiting: true } : { text: 'Their turn', waiting: false } }, timers: { close(state, data, ctx) { if (state.winner) ctx.end() }, }, } ``` Tapping the "Your turn" notification for game `abc` opens your app at its `address`, `game/abc`. `finish` saves the final board as [room data](https://tinylib.app/docs/guide/rooms.md#keep-data-with-a-room), which players can read after the game ends. Its timer ends the room a day later, unless the players play again. `status` is each player's line in their list of games, and `followGames` lists the games waiting on the user first. #### Switch between the list and a game The app has two screens: the list of games at the address `''`, and one game at `game/`. [`route`](https://tinylib.app/docs/api/kits.md#route) starts the screen for the current [address](https://tinylib.app/docs/guide/places.md) and stops it when the address changes. Each screen is a function that draws it and returns a function that stops it: ```js app.js import { followGame, followGames, playAgain, route } from 'tinylib-sdk/kits' route({ '': showGames, game: showGame, // 'game/abc' calls showGame('abc') }, { keep: true }) ``` With `keep: true`, going to the game already showing leaves it alone instead of starting it again. With React Router or another router, return `followGame`'s stop function as the cleanup: ```js useEffect(() => followGame(id, setGame), [id]) ``` #### Start a game and invite a friend Create the game with `tinylib.rooms.create('fourInARow')` and go to `game/`. Until a friend joins, the view's `started` is false, so show a button that calls [`tinylib.rooms.invite(id)`](https://tinylib.app/docs/guide/rooms.md#invite-people). #### List the user's games [`followGames(kind, fn)`](https://tinylib.app/docs/api/kits.md#followgames) calls `fn` with the user's games of that kind, and again whenever one changes. It returns the stop function `route` needs: ```js app.js function showGames() { return followGames('fourInARow', ({ current, past, invitations, loading, online, error }) => { renderGames(current, past) renderInvitations(invitations) noGames.hidden = loading || !online || current.length > 0 gamesMessage.textContent = error?.message ?? (online ? '' : "Your games come back when you're online.") }) } ``` `current` holds games in progress, those waiting on the user first, each with its `status`. `past` holds ended games, then games the user left. Offline or while `loading`, `current` can be empty even when the user has games. #### Show invitations While `followGames` or `followGame` runs, invitations to that kind's games come to your function as `invitations` instead of to Tinylib's own sheet, so they don't cover the board. Draw them on both screens: ```js app.js function renderInvitations(invitations) { invitationList.replaceChildren(...invitations.map((invitation) => invitationCard(`${invitation.from.name} wants to play`, { async play() { const { id } = await invitation.accept() tinylib.places.go(`game/${id}`) }, dismiss: () => invitation.dismiss(), block: () => tinylib.block(invitation.from.id), report: () => tinylib.report(invitation.from.id), }))) } ``` An invitation shows another user, so it needs [Block and Report](https://tinylib.app/docs/guide/friends.md#let-users-block-and-report). #### Play a game [`followGame(id, fn)`](https://tinylib.app/docs/api/kits.md#followgame) opens the game, calls `fn` whenever it changes, and returns a function that closes it: ```js app.js function showGame(id) { return followGame(id, (game) => { renderInvitations(game.invitations) if (game.past?.left) renderLeft(game.past) else if (game.past && !game.view) renderFinished(id, game.past) else if (game.error) showMessage(game.error.message) else if (game.view) renderBoard(game.view, game.room) else showSpinner() // ... the rematch button, below }) } ``` - `view`: what the rules' `view` returned for this user, here `{ board, turn, winner, started, you, round }`. - `room`: the open room to act in. Its `ended` turns true when the room ends. - `error`: why the game couldn't be opened. - `past`: the game's entry in the user's list, once it has ended or the user has left. All four are null until the game loads. A tap on a column calls `room.act('drop', column)`, and a refusal's message, like "It's not your turn.", is the user's to read. #### Resign or leave a game A `resign` action makes the other player the winner and calls `finish`, which keeps the final board: ```js rules.js export const fourInARow = { // ... actions: { // ... resign(state, data, ctx) { if (state.players.yellow === null) ctx.refuse("The game hasn't started.") state.winner = ctx.user.id === state.players.red ? 'yellow' : 'red' ctx.notify(state.players[state.winner], { title: 'You won', text: `${ctx.user.name} resigned.` }) finish(state, ctx) }, }, } ``` A player can also leave from Tinylib, or by blocking the other player. The kind's `leave` then runs. Nobody can take the empty seat, so it ends the room: ```js rules.js export const fourInARow = { // ... leave(state, why, ctx) { if (state.players.yellow === null) return if (!state.winner) { state.winner = ctx.user.id === state.players.red ? 'yellow' : 'red' finish(state, ctx) } ctx.end() }, } ``` #### Play again in the same room An `again` action starts the next game in the same room, so both players stay in it, guests included. It swaps the colors and cancels the timer. The page sends the round it showed, so a second tap on Rematch does nothing: ```js rules.js export const fourInARow = { // ... actions: { // ... again(state, round, ctx) { if (round !== state.round) return if (!state.winner) ctx.refuse('The game is still on.') const { red, yellow } = state.players Object.assign(state, { board: Array(42).fill(''), players: { red: yellow, yellow: red }, turn: 'red', winner: null, round: state.round + 1 }) ctx.cancelTimer('close') ctx.data.room.remove('final') }, }, } ``` ```js app.js // in showGame's followGame callback: again.hidden = !game.view?.winner || game.room.ended again.onclick = () => game.room.act('again', game.view.round) ``` #### Keep a slow game open A friend who stops playing doesn't leave the game. Tinylib [closes](https://tinylib.app/docs/guide/rooms.md#end-a-room) a game once nobody has used it for the kind's `idleMs`. The default is 7 days, and a slow game can set up to 365 days: ```js rules.js export const fourInARow = { address: 'game/:id', idleMs: 30 * 24 * 60 * 60 * 1000, // ... } ``` A user can be in 50 rooms at once in your app, counting games still waiting for a friend. Past that, starting a game or accepting an invitation rejects with `limit`, so offer a way to resign or leave. #### Show a finished game A game that ended, or that the user left, has `past` and no `view`. `past.status` is the user's last status, like "You won", and `past.members` are who was in the game at the end. To show the final board, read what `finish` saved: ```js app.js try { const final = await tinylib.rooms.data(id).get('final') if (final) renderFinalBoard(final) } catch (error) { if (error.code !== 'offline') throw error } ``` A user who left sees the room data from before they left, so they get no `final`. Neither does anyone in a game that ended without `finish`, which has [`past.closed`](https://tinylib.app/docs/api/kits.md#game) true. #### Offer a rematch `game.rematch` is the other player's rematch invitation, or null. [`playAgain(game)`](https://tinylib.app/docs/api/kits.md#playagain) accepts it, or else starts a new game and invites the other player. It resolves with the `{ id }` of the game to open: ```js app.js // in showGame's followGame callback: again.hidden = !game.past && !game.room?.ended again.textContent = game.rematch ? `Play ${game.rematch.from.name} again` : 'Rematch' again.onclick = async () => { try { const { id } = await playAgain(game) tinylib.places.go(`game/${id}`) } catch (error) { toast(error.message) } } ``` A game whose room ended more than a day ago gets a new game, with ordinary invitations instead of a rematch. If both players tap Rematch at once, each starts a game, and one of them gets the other's invitation as `game.duplicate`. Show it to them; `playAgain(game)` then joins that game and leaves their own, so go to it with `{ replace: true }`. A guest gets no invitation from `playAgain`, and a guest's own tap opens Tinylib's offer of an account. To play again with guests, [reset the room](https://tinylib.app/docs/guide/rooms.md#play-again) instead of ending it. #### Try the game in dev In `npx tinylib-sdk dev`, start a game in one pane, invite the simulated user in another, and play both seats. ## Friends > Share data with the user's friends who use your app, and send them the app. Source: https://tinylib.app/docs/guide/friends.md `tinylib.friends` lists the user's friends who use your app and reads what they save for friends. It also opens Tinylib's send [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets), where the user picks friends to send your app to. ### How friends work Friendships are mutual, and people make them in Tinylib, outside your app. Your app can't add or remove a friend. Add `"friends"` to [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses) in `tinylib.json`: ```json tinylib.json { "uses": ["friends"] } ``` The first time a user opens your app, Tinylib's onboarding tells them your app can share data with their friends. There's no separate permission to ask for. Tinylib's onboarding sheet for an app that uses friends, which tells the user the app can share data with their friends. For a [guest](https://tinylib.app/docs/guide/rooms.md#guests), a user without a Tinylib account, every friends call rejects with `refused`. A call made as the user taps, such as `friends.sendApp()` behind a Send to a friend button, first opens Tinylib's offer of an account, so such a button can stay for a guest; a list or leaderboard of friends can't be filled for one, so check `tinylib.user.guest` before reading it. In `npx tinylib-sdk dev`, the simulated users are all friends with each other. ### Share a value with friends To let the user's friends read a value, save it with `audience: 'friends'`: ```js tinylib.data.set('best', 41, { audience: 'friends' }) ``` A later save without `audience` keeps it shared, and `audience: 'me'` makes it private again. Keep a score friends should trust in a [key only the rules write](https://tinylib.app/docs/guide/data.md#keys-only-the-rules-write), which the rules share the same way. ### Show a leaderboard of friends `tinylib.friends.list()` resolves with the user's friends who use your app, sorted by name, each as `{ id, name }`. `tinylib.friends.data.get(key)` resolves with a `Map` from each friend's id to `{ value, changedAt }`, for each friend who shared a value under `key`. Neither includes the user, so add their own row from `tinylib.data`: ```js app.js try { const [friends, wins] = await Promise.all([tinylib.friends.list(), tinylib.friends.data.get('wins')]) const board = [ { name: tinylib.user.name, wins: tinylib.data.get('wins') ?? 0 }, ...friends.map((friend) => ({ name: friend.name, wins: wins.get(friend.id)?.value ?? 0 })), ] board.sort((a, b) => b.wins - a.wins) drawBoard(board) } catch (error) { if (error.code !== 'offline') throw error document.querySelector('#board-offline').hidden = false } ``` Every friends call rejects with `offline` without a connection. Nothing tells your client when a friend's value changes, so read it again each time you show it. `changedAt` is on the same clock as [`tinylib.now()`](https://tinylib.app/docs/api/tinylib.md), so you can leave out old scores. ### Read one friend's values `tinylib.friends.data.of(id)` has the read methods of `tinylib.data` for what one friend shared. Each returns a promise, and each value comes as `{ value, changedAt }`: ```js const days = await tinylib.friends.data.of(friend.id).entries('day/') // [['day/2026-10-20', { value: 280, changedAt: 1792540800000 }], …] ``` For anyone who isn't the user's friend in your app, it's empty. ### Send your app to friends `tinylib.friends.sendApp()` opens Tinylib's send sheet. It resolves when the sheet closes, and your app never learns who the user picked. An empty leaderboard is a good place to offer it: ```js app.js document.querySelector('#invite-friends').addEventListener('click', async () => { try { await tinylib.friends.sendApp() } catch (error) { toast(error.message) } }) ``` Tinylib's send sheet, where the user picks friends to send the app to. ### Show other users' names Tinylib has no call that turns a user id into a name. Take each name from the list its id came from: | An id from | Is named by | | --- | --- | | `tinylib.friends.list()`, `tinylib.friends.data` | `tinylib.friends.list()` | | `ctx.members` in the rules | `ctx.members` | | A room's data | The room's `members` in `tinylib.rooms.list()` | These lists have current names, and leave out blocked users and users whose data in your app was deleted. When an id is missing from its list, drop its row rather than show it without a name. ### Let users block and report If your app shows people to each other, the [Developer Terms](https://tinylib.app/developer-terms) require it to let them block and report each other through Tinylib. `tinylib.block(id)` and `tinylib.report(id)` open Tinylib's own sheets. Each resolves `true` if the user went through with it and `false` if they backed out, and rejects with `offline` without a connection: ```js app.js try { if (await tinylib.block(friend.id)) await showLeaderboard() // the friends list now leaves them out } catch (error) { toast(error.message) } ``` Blocking someone ends the friendship, and the user who blocked leaves every room they share with that person. ## Places and links > Route between screens, and link users to a place in your app. Source: https://tinylib.app/docs/guide/places.md `tinylib.places` names each screen in your app by its address: the page's path without its leading `/`, plus any query string. At `/game/abc?tab=moves`, the address is `game/abc?tab=moves`. The address leaves out any `#` fragment, and the empty address, `''`, is your start screen. ### Route between screens Your app runs on its own origin, and Tinylib serves your `index.html` at every path that isn't one of your [published files](https://tinylib.app/docs/guide/publishing.md). A router that uses the browser's History API, such as React Router, works without a `basename`: ```js app.jsx import React from 'react' import { createRoot } from 'react-dom/client' import { createBrowserRouter, RouterProvider } from 'react-router' import { Game, Start } from './screens.jsx' const router = createBrowserRouter([ { path: '/', element: }, { path: '/game/:id', element: }, ]) createRoot(document.querySelector('#root')).render() ``` Without a router, read `tinylib.places.address` when your page starts, and again each time it changes: ```js function showPlace() { const [path, query] = tinylib.places.address.split('?') const [screen, id] = path.split('/') if (screen === 'game' && id) return showGame(id) if (screen === 'settings') return showSettings(new URLSearchParams(query)) showStart() } tinylib.places.onChange(showPlace) showPlace() ``` `onChange` runs after every change of address, including the ones Tinylib makes. When the user accepts an invitation while your app is open, Tinylib takes them to the room's address and `onChange` runs. For a game with a list of games and one game screen, [`route`](https://tinylib.app/docs/api/kits.md#route) in `tinylib-sdk/kits` does this routing for you. ### Change screens `tinylib.places.go` changes screens without loading a new page: ```js tinylib.places.go(`game/${id}`) tinylib.places.go('', { replace: true }) ``` After a tap, back returns the user to the screen they left. A move with no tap, such as a redirect from a screen your app refuses, takes the current screen's place, so back never returns to a screen that would only send them away again. `replace` does the same after a tap. A move to your start screen goes back to it when it's beneath, and otherwise takes the current screen's place. `go` throws a `TypeError` for an address that starts with `/`. ### Leave your app Back leaves your app from the screen it opened at. To offer a way out from any screen, draw your own button and call `tinylib.places.exit` from its tap, which takes the user to their Library. It rejects with `refused` with no tap, or while one of Tinylib's sheets is open: ```js exitButton.onclick = () => tinylib.places.exit() ``` ### Link between screens A link to a path in your app changes screens as `go` does. A link to another site opens in a new tab on the web, and in the phone's browser in Tinylib's Android app. ```html Settings Back to the game The rules of tic-tac-toe ``` ### Share a link to a screen `tinylib.places.share` opens the device's share sheet with a link to a screen, or Tinylib's own sheet where the device has none: ```js await tinylib.places.share(`list/${id}`, 'Our shopping list') ``` The link is your app's slug and the address, such as `https://tinylib.app/groceries/list/abc`. Anyone can open it, with or without a Tinylib account. The first time, the user taps Open: a user new to Tinylib gives their name there, and an app that uses friends says so there. A shared link doesn't let anyone into a room, because a room shows only to its members. To add someone, invite them with [`tinylib.rooms.invite`](https://tinylib.app/docs/api/rooms.md#invite) or share the room's link from [`tinylib.rooms.getLink`](https://tinylib.app/docs/api/rooms.md#getlink). ### Where users arrive from outside Tinylib opens your app at an address when the user: - taps a reminder: the reminder's `address` - taps a room's notification, picks a room in Tinylib's lists of rooms, or joins a room by an invitation or its link: the address of the room's kind - opens a link to your app: the address in the link Each kind's `address` in your rules sets where its rooms open, with `:id` replaced by the room's id: ```js rules.js export const game = { address: 'game/:id', // ... } ``` `:id` can go anywhere in the address, for example `'?list=:id'`. A kind with no `address` opens your start screen, and your app can't tell which room the user came for. ## Reminders and notifications > Schedule reminders for one user, and notify a room's members. Source: https://tinylib.app/docs/guide/reminders.md Your app can reach a user in two ways while it isn't open. Your client sets reminders for the user, for example a nudge to write each evening. Your rules notify one member of a room, for example when someone joins their game. Your app can't notify anyone else. | | Reminder | Room notification | | --- | --- | --- | | Sent by | Your client, with `tinylib.reminders.set` | Your rules, with `ctx.notify` | | Reaches | The user, on each of their devices | One member, unless they have the room open | | When | At the time you set | As soon as your rules send it | | The user's switch | **Reminders** | **Rooms** | | Declared in [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses) as | `"reminders"` | `"notifications"` | A reminder your client sets goes off on the device and syncs through Tinylib's servers to the user's other devices, each showing it once. A room's notification goes from your rules through Tinylib's servers. Tapping either one opens your app. ### Set a reminder To remind the user, call `tinylib.reminders.set` with a name you choose: ```js await tinylib.reminders.set('evening', { title: 'Time to write', text: 'How was today?', repeat: tinylib.reminders.daily('18:00'), address: 'today', }) ``` At its time, the reminder shows on each of the user's devices that receive notifications, with your app's name and icon above your `title` and `text`. Setting the same name again replaces that reminder. A user can have 32 of your app's reminders at once. Past that, `set` with a new name rejects with `limit`. ### Schedule once or on repeat A reminder has exactly one of `at` and `repeat`. For a one-off, give `at` a time on the device's clock as `'YYYY-MM-DDTHH:MM'`. Or give it a timestamp in milliseconds from [`tinylib.now()`](https://tinylib.app/docs/api/tinylib.md#now), which follows Tinylib's clock rather than the device's: ```js await tinylib.reminders.set('dentist', { title: 'Leave for the dentist', at: '2027-03-04T14:30' }) await tinylib.reminders.set('tea', { title: 'Tea is ready', at: tinylib.now() + 4 * 60_000 }) ``` `repeat` takes a schedule from `daily`, `weekly`, or `monthly`. The reminder starts at the next time the schedule matches: ```js await tinylib.reminders.set('standup', { title: 'Stand-up', repeat: tinylib.reminders.weekly(['mon', 'tue', 'wed', 'thu', 'fri'], '09:30'), }) await tinylib.reminders.set('rent', { title: 'Pay the rent', repeat: tinylib.reminders.monthly(1, '08:00') }) ``` ### Time zones A reminder set for a clock time keeps the time zone of the device that set it. One set for 6pm in Atlanta goes off at 6pm Atlanta time, wherever the user is and across clock changes. To move a reminder to the user's current time zone, set it again. `list()` gives each reminder's time zone as `timeZone`. ### Without a connection `set`, `list`, and `cancel` work offline. A reminder set offline still goes off on that device at its time: in Tinylib's Android app even while Tinylib is closed, and on the web while Tinylib is open in a tab. Once the device reconnects, the reminder reaches the user's other devices too. A [guest](https://tinylib.app/docs/guide/rooms.md#guests) has no other devices, so their reminder goes off only on the device that set it. A device that can't show a reminder on time, for example because it was switched off, shows it up to 1 hour late, and otherwise skips it. ### List and cancel reminders ```js const reminders = await tinylib.reminders.list() await tinylib.reminders.cancel('evening') ``` Each reminder in the list has what it was set with, plus `next`, when it next goes off. The user can also cancel reminders on your app's page in Tinylib, so call `list()` again each time a screen shows them. For a daily reminder the user turns on and off in your app, [`followDailyReminder`](https://tinylib.app/docs/api/kits.md#followdailyreminder) in `tinylib-sdk/kits` keeps your switch in step. ### Notify a room's members To tell a member something needs them while they're away, call `ctx.notify` in a handler with their id. Tinylib sends nothing while the member has the room open on any device, and never notifies a [guest](https://tinylib.app/docs/guide/rooms.md#guests), who has no Tinylib account. Tapping the notification opens your app at the kind's `address`, so [give the kind one](https://tinylib.app/docs/guide/places.md#where-users-arrive-from-outside): ```js rules.js 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 } }, join(state, via, ctx) { state.players.o = ctx.user.id ctx.notify(state.players.x, { title: `${ctx.user.name} joined`, text: 'Your move.' }) }, // ... } ``` Notify sparingly, when something waits on the member: it's their turn, or a list they shared is finished. Tell the member it waits on, not every member about every change; the others see it in the room's [status](https://tinylib.app/docs/guide/rooms.md#statuses-and-notifications) and its view when they open it: ```js rules.js export const list = { // ... actions: { check(state, item, ctx) { state.done[item] = true const finished = state.items.every((each) => state.done[each]) if (finished && ctx.user.id !== state.owner) ctx.notify(state.owner, { title: 'List done', text: `${ctx.user.name} finished it.` }) }, }, } ``` A member has one notification from a room at a time. A newer one replaces it and alerts again, so a member who missed three moves sees only the last. Opening or leaving the room clears it on that device and on the member's Android phones at once, and in another browser once the room opens there. A title longer than 80 characters or a text longer than 300 characters isn't sent, and the error shows on [the developer page](https://tinylib.app/developer). A room sends at most 60 per hour to one member and 300 per hour in all, and Tinylib drops the rest. ### Handle a tap Tapping a reminder or a room's notification opens your app at its address, or at your start screen when a reminder has none. If your app is already open, it keeps running and moves to that address, and `tinylib.places.onChange` calls your function. Your app learns only the address, not what the user tapped, so route on it as [Places and links](https://tinylib.app/docs/guide/places.md#route-between-screens) shows. To tell a reminder's arrival from any other, put something in its address, for example `today?from=evening`. ### Notification permission Your app never asks for permission to notify. Tinylib asks, the first time your app sets a reminder or creates or joins a room on a device that hasn't been asked yet. `tinylib.user.settings` holds your app's switches, not the device's permission. [Platforms](https://tinylib.app/docs/guide/platforms.md#notifications-and-reminders) says which devices get notifications. ### Respect the user's switches Your client reads the switches as `tinylib.user.settings.reminders` and `tinylib.user.settings.notifications`, the field for **Rooms**. Both start on. Only the user changes them, on your app's page in Tinylib or in the [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) that [`tinylib.user.manageSettings()`](https://tinylib.app/docs/api/tinylib.md#user-managesettings) opens over your app. To offer the switch where the user expects reminders: ```js const turnOn = document.querySelector('#turn-on-reminders') function update() { turnOn.hidden = tinylib.user.settings.reminders } update() tinylib.user.onChange(update) turnOn.addEventListener('click', async () => { try { await tinylib.user.manageSettings() } catch (error) { toast(error.message) } }) ``` While Reminders is off, none of your app's reminders goes off, though they stay set and `set` still works. A time that passes meanwhile is skipped: a repeating reminder moves on to its next time, and a one-off is gone. While Rooms is off, or the user has muted a room, your rules' notifications don't reach them. [`tinylib.rooms.list()`](https://tinylib.app/docs/api/rooms.md#list) gives a room the user muted `muted: true`. ### Test in development In `npx tinylib-sdk dev`, each user's pane has a Notifications list of what would reach them, including what a switch or a muted room held back. Tapping one opens the app at its address. The clock's buttons skip ahead, so a reminder comes due without waiting. ## Publishing > Publish and preview your app, and move its open rooms to new rules. Source: https://tinylib.app/docs/guide/publishing.md `npx tinylib-sdk publish` uploads your app's folder as a new version, a numbered copy of your files and rules that never changes. The newest version goes live for everyone at once. In a listed app, each new version first waits for [Tinylib's review](https://tinylib.app/docs/guide/publishing.md#review-for-listed-apps). ```sh npx tinylib-sdk publish ``` A run looks like this: ```text ◆ Tinylib publish ✓ Built Tic-tac-toe: 4 files, 18.2 KB, with rules. Uses notifications (ctx.notify at rules.js:31). ✓ Published Tic-tac-toe as version 3. Its 2 open rooms run the new rules from their next input, after migrate. https://tinylib.app/tic-tac-toe ``` Version 3 becomes current for everyone at once. A client open in a room reloads onto client 3, and each room keeps its state and runs migrate as it moves from rules 2 to rules 3. A preview runs separately, for you and your testers. ### What a version includes Every file in the folder and its subfolders ships, at the same path, except: - files and folders whose names start with a dot, such as `.env` and `.git` - `node_modules` - `package.json` and `package-lock.json` - `tinylib.json`, `rules.js` or `rules.ts`, and files that only the rules import, since the rules run on Tinylib's servers and reach a device only if your page imports them > Every file that ships is public, a README or `tsconfig.json` included. Keep secrets out of the folder, or publish [a bundler's output](https://tinylib.app/docs/guide/publishing.md#publish-a-bundler-s-output). A version needs `index.html` and the icon `tinylib.json` names, a square PNG at least 512 pixels on each side. It's refused past 5 MB or 500 files, or past one of the other [limits on versions](https://tinylib.app/docs/reference/limits.md#apps-and-versions). ### How publish builds your files `publish` builds each file the way `npx tinylib-sdk dev` serves it: | File | What `publish` does | | --- | --- | | `.html` | Adds the tag that loads Tinylib's script first in ``. Moves each inline ` ``` Imports of npm packages resolve from `node_modules`, and imported JSON is inlined. TypeScript and JSX are compiled without checking types. JSX compiles to `React.createElement` calls, so a file that uses JSX imports React. To use React's automatic JSX runtime instead, put a `tsconfig.json` in the folder: ```json tsconfig.json { "compilerOptions": { "jsx": "react-jsx" } } ``` A script that imports CSS or an image fails the build. Link stylesheets from the page, and load images by their path. Every script in the folder is bundled, even one no page loads, so a test file that imports `node:test` stops `publish` with "*path* didn't build". Keep tests out of the folder you publish. ### Publish a bundler's output To use Vite or another bundler, publish its output folder. `publish` reads `tinylib.json`, the icon, and the rules from that folder, so put them and the files the rules import where the bundler copies files unchanged, like Vite's `public/` folder: ```sh npx vite build npx tinylib-sdk publish dist ``` Set the bundler's base path to `/`. ### Preview a change with testers `npx tinylib-sdk preview` publishes the folder as your app's preview, a separate copy that only you and the testers you invite can open. Publish the app first. ```sh npx tinylib-sdk preview --tester ben@example.com ``` Each tester gets an email with the preview's link and signs in to Tinylib with that address to open it. A preview has its own data and rooms. An app has one preview. Running `preview` again replaces its client and rules and restarts its expiry. Its data, rooms, and testers stay, and its rooms move to the new rules [as the live app's do](https://tinylib.app/docs/guide/publishing.md#change-the-rules-of-a-live-app). A preview never becomes the live app: to go live, run `publish`. A preview ends when it expires or when you run `npx tinylib-sdk preview --end`, and its data and rooms go with it. The [developer page](https://tinylib.app/developer) can also add and remove testers and end the preview. ### Choose who finds your app A new app starts unlisted. Choose who finds it on the developer page: | Visibility | Who finds it | | --- | --- | | Unlisted | Anyone with its link or an invitation, and anyone a friend sends it to | | Friends | Your friends too, in Discover | | Listed | Everyone, in Tinylib's directory, once Tinylib approves it | ### Review for listed apps Listing an app asks for review. A person at Tinylib checks it against the [Developer Terms](https://tinylib.app/developer-terms). The app stays out of the directory until they approve it. After that, each new version waits for the same review: - `publish` puts the new version in the app's preview, replacing what it held, so your testers try what Tinylib reviews. - Everyone keeps the current version until Tinylib approves the new one. - On approval, the [room check](https://tinylib.app/docs/guide/publishing.md#change-the-rules-of-a-live-app) runs again and the version goes live. Tinylib emails your contact address when it approves or rejects a version, and the developer page shows the reason for a rejection. To ask again, publish a fixed version. Choosing Unlisted or Friends takes the app out of the directory and withdraws any version waiting for review; later versions go live at once. Listing the app again asks for a new review. ### How open clients update When a version goes live, a client open in a room reloads at its current address, with no event first, and any other client uses the new version at its next open. A client that was offline reloads once it's back online, and an action it sends before reloading is refused rather than run under the new rules. The reload loses anything kept only in the page's memory, so save a half-typed message to `tinylib.data` as the user types. ### Change the rules of a live app Rooms run the current version's rules on state that earlier rules may have written, so new rules must handle the old shape or rooms in progress break. To reshape old state, give the kind a `migrate` handler. It runs once in each room under new rules, before anything else touches the state. `fromVersion` is the version whose rules last handled an input in the room, which can be several versions back. ```js rules.js export const game = { // ... migrate(state, fromVersion, ctx) { if (fromVersion < 4) state.scores ??= {} // version 4 added scores }, } ``` Once `migrate` succeeds, [`ctx.version`](https://tinylib.app/docs/rules/ctx.md#version) becomes the current version's number. Before a version goes live, Tinylib runs a room check on every room that hasn't ended, idle ones included. It runs `migrate` on a copy of the room's state, then each member's `view`, `status`, and `canInvite`. If one of them throws, `migrate` refuses, or the result passes one of a room's [limits](https://tinylib.app/docs/reference/limits.md#rooms), the version doesn't go live and nothing changes: ```text ✗ The new rules fail on 2 of the app's 5 open rooms, so this version didn't go live. Nothing changed. - 2 rooms of the kind "game": Cannot read properties of undefined (reading 'x') ``` The check also runs for every preview and for every earlier version you make current again. It refuses a version that removes a kind while rooms of that kind haven't ended, or that has no `rules.js` while any room hasn't ended. To retire a kind, make its `create` refuse, and remove the kind once its last room has ended. > In an [offline kind](https://tinylib.app/docs/guide/rooms.md#offline-kinds), an action a member made without signal under an earlier version is refused when it reaches the room, even when only the client changed. Tinylib takes it out of `room.pending` and tells the user a change couldn't be saved. ### Read data that earlier versions wrote Publishing doesn't reshape the user's data, so a new client reads whatever shape earlier clients and rules wrote. Read old shapes as well as new ones: ```js const sound = tinylib.data.get('sound') ?? !tinylib.data.get('muted') // earlier versions saved `muted` ``` The same goes for a member's data under `rulesKeys` and for room data: `migrate` reshapes only the room's state. ### Go back to an earlier version To undo a bad version, choose **Make current** beside an earlier version on the app's developer page. The command line has no rollback. Tinylib copies that version into a new one with the next number, which goes live like any other: it must pass the room check, and in a listed app it waits for review. Rooms don't go back. They run the copy's `migrate` with `fromVersion` set to the version they ran last, so the earlier rules meet state that later rules may have reshaped. If they can't handle it, the room check refuses the rollback. ### Unpublish or delete an app Both are on the developer page. After **Unpublish**, people who have the app keep using it, but nobody new can open it, and it leaves the directory and Discover. Publishing again brings it back, unlisted. **Delete** deletes every version and everything people saved in the app, and removes it from every library. You can't undo it. ### Your app's slug Your app's links carry the `slug` from `tinylib.json`, as in `tinylib.app/tic-tac-toe`, and the app's first version claims it. A slug that belongs to another developer's app or to one of Tinylib's own addresses is taken, and a slug is never used again, even after its app is deleted. When the slug is taken the first time you publish, `publish` offers an available one like it, such as `tic-tac-toe-k7q`, and writes it to `tinylib.json` if you accept. Outside a terminal, it prints the available slug and stops. A slug or a `name` that Tinylib's word filter catches, or that names Tinylib or another company, is refused as taken. > An app's slug can't change. A new slug in `tinylib.json` publishes a separate new app, and the old one stays as it was, with its users. ## Errors and offline > Handle calls that fail, and know what works without a connection. Source: https://tinylib.app/docs/guide/errors.md ### Handle a failed call A call fails with a [`TinylibError`](https://tinylib.app/docs/api/tinylib.md#tinyliberror), an `Error` with a `code` that says what happened and a `message` you can show the user. A refusal from your rules carries your own sentence. This form joins a room by its short code, and shows a code that matches no room as the field's own error: ```html
``` ```js const form = document.querySelector('#join') const codeField = form.elements.code codeField.oninput = () => codeField.setCustomValidity('') form.onsubmit = async (event) => { event.preventDefault() try { const { id } = await tinylib.rooms.join(codeField.value) tinylib.places.go(`game/${id}`) } catch (error) { if (error.code === 'not_found') { codeField.setCustomValidity(error.message) codeField.reportValidity() } else if (error.code) { toast(error.message) } else { throw error } } } ``` | Code | What happened | What to do | | --- | --- | --- | | `refused` | Tinylib or your rules said no, and `message` says why: the rules called `ctx.refuse`, the call needs a Tinylib account and the user has none, your app's `uses` lacks what the call needs, or one person has blocked the other. | Show `message`. | | `failed` | Something went wrong that the user didn't cause. Your rules threw or have no handler for the input, or Tinylib's servers or the device failed. | Show `message`. When your rules caused it, your [developer page](https://tinylib.app/developer) shows the error; otherwise, trying again later may work. | | `offline` | The call needs Tinylib's servers, and the device has no connection. | Wait for `tinylib.connection.onChange`, then let the user try again. A request already on its way may have [arrived](https://tinylib.app/docs/guide/errors.md#when-a-request-loses-its-connection). | | `limit` | The call would pass one of the [limits](https://tinylib.app/docs/reference/limits.md). | Show `message`. A rate allows more later, and a size or a count allows more once something is removed. | | `not_member` | The user isn't a member of the room, or no room has that id. Tinylib doesn't say which. | Take the user out of the room's screen. | | `ended` | The room has ended, or the user deleted their data in your app. | Take the user out of the room's screen. | | `not_found` | No short code, link, invitation, or person matches what the call was given. | Ask the user to check the code or link they used. | Each call's reference lists the codes it can fail with, and any call that reaches Tinylib's servers can also fail with `failed`. A call given arguments it can't take, such as `tinylib.places.go('/game')`, throws a `TypeError`, or rejects with one if it's asynchronous. Its message is for you, not the user. ### Handle a dismissed sheet Closing one of Tinylib's [sheets](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) without choosing isn't an error. `block` and `report` resolve `false`, `user.manageSettings` resolves with the settings unchanged, and the other calls that open a sheet resolve when it closes: ```js const blocked = await tinylib.block(member.id) if (blocked) tinylib.places.go('') ``` ### Without a connection While a room your app has open can't reach Tinylib, Tinylib shows so over your app, so your app doesn't show its own. `tinylib.connection.online` says whether Tinylib's servers can be reached, and `onChange` calls your function when that changes: ```js const newGame = document.querySelector('#new-game') tinylib.connection.onChange(() => { newGame.disabled = !tinylib.connection.online }) ``` A call that needs a connection rejects with `offline` at once when there's none. The others work with what the device keeps: | Call | Without a connection | | --- | --- | | `tinylib.data` | Reads and writes the device's copy | | `tinylib.reminders.set`, `list`, and `cancel` | Work on the device's copy | | `tinylib.user`, `tinylib.places`, and `tinylib.now()` | Work | | `rooms.open` | Opens from the device's copy in an [offline kind](https://tinylib.app/docs/guide/rooms.md#offline-kinds), and otherwise opens once the device is back online | | `room.act` | Works in an offline kind, and rejects with `offline` otherwise | | `room.relay` | Sends nothing, without an error | | `room.close` | Works | | `rooms.list` | Resolves with the ended and left rooms the device last heard of | | `rooms.data` | Reads an ended or left room's data the device has read before, and rejects with `offline` otherwise | | `rooms.create`, `join`, `invite`, `getLink`, and `leave` | Reject with `offline` | | `tinylib.friends` | Every call rejects with `offline` | | Listing, accepting, and dismissing invitations | Reject with `offline` | | `user.manageSettings`, `block`, and `report` | Reject with `offline` | Writes and reminders made offline are sent when the connection returns. [`tinylib.data.onChange`](https://tinylib.app/docs/api/data.md#onchange) reports a write that fails once it arrives. Your app opens offline once it has opened online on that device, except that Safari may delete its copy ([Platforms](https://tinylib.app/docs/guide/platforms.md#opening-your-app-offline)). When the connection drops, an open room disconnects, and Tinylib reopens it with the current view when the connection returns. Meanwhile, `room.act` rejects with `offline` outside an offline kind, and events the rules emit and other members' relays are missed. ### When a request loses its connection A request that loses its connection after it left the device rejects with `offline`, and Tinylib can't tell whether it arrived. A `room.act` may have run, and once the room reopens, its view shows whether it did. A `rooms.create` may have made a room, so check `tinylib.rooms.list()` before creating another. An offline kind doesn't have this problem: the device sends each action until the room has it, and the room runs each one once. ### Errors in the rules When the rules throw, or an input runs past 50 milliseconds of CPU time, the input changes nothing and sends nothing. If it was a member's `rooms.create` or `room.act`, the call rejects with `failed`. An error never ends a room. A join or a leave still happens when its handler throws, and only the rules' changes from it are dropped. The member is then in the room but not in the state, so keep `join` and `leave` from throwing, and write your other handlers to cope with a member the state doesn't know. When something a handler asks for can't be done, such as a notification whose text is past 300 characters, Tinylib skips it and keeps the rest of the input. The error shows on your developer page. ### Find errors from live rooms In `npx tinylib-sdk dev`, the inspector shows everything your rooms do as it happens, and `console.log` in your rules prints to the browser's console. In live rooms, `console.log` goes nowhere. Errors from live rooms show on your app's developer page, each with its kind, its input, the version, how many times it happened, and the line in your files where it happened. Tinylib never shows you a room's state or an input's data, and the [Developer Terms](https://tinylib.app/developer-terms) forbid putting people's data in error messages. ## Platforms > What differs on the web, in Safari, on an iPhone, in the Android app, and in dev. Source: https://tinylib.app/docs/guide/platforms.md Your app runs the same code everywhere Tinylib runs. What differs is what the device lets Tinylib do around it: notifications, reminders, opening offline, the screen, and sharing. | Where | What it is | | --- | --- | | Web | Tinylib in Chrome, Edge, or Firefox on a computer or an Android phone, including installed from Chrome | | Safari | Tinylib in Safari on a Mac, or in any browser on an iPhone or iPad | | Home Screen | Tinylib added to an iPhone's or iPad's Home Screen | | Android app | Tinylib from Google Play | | `dev` | A pane in `npx tinylib-sdk dev`, which behaves like the web | There's no iPhone app. On an iPhone, Tinylib runs in the browser or from the Home Screen. ### Notifications and reminders The user allows notifications once for Tinylib on each device, when an app first needs them ([Notification permission](https://tinylib.app/docs/guide/reminders.md#notification-permission)). | Where | A room's notifications | Reminders | | --- | --- | --- | | Web | Arrive, even while Tinylib is closed | Go off at their time, even while Tinylib is closed. One set offline goes off only while Tinylib is open in a tab. | | Safari on a Mac | As on the web | As on the web | | Safari on an iPhone or iPad | Never arrive | Never go off | | Home Screen | Arrive, even while Tinylib is closed | Go off at their time, even while Tinylib is closed. One set offline goes off only while Tinylib is open. | | Android app | Arrive, even while Tinylib is closed | Go off at their time, even when set offline and while Tinylib is closed | | `dev` | Show in the user's Notifications list | Show in the user's Notifications list, on `dev`'s clock. One set in an offline pane still goes off at its time. | ### Opening your app offline | Where | Opens your app without a connection | | --- | --- | | Web and Android app | Once it has opened there online | | Safari and Home Screen | Once it has opened there online, while Safari keeps its files, which it may delete | | `dev` | Once that pane has opened it online. Each pane's Online switch tries it. | ### The screen [`orientation` and `fullscreen`](https://tinylib.app/docs/reference/tinylib-json.md) in `tinylib.json` apply differently: | Where | Full screen | Orientation | | --- | --- | --- | | Web | From the tap that opens your app, or else the user's first tap in it, and again at their next tap if they leave | Held in full screen on an Android phone, never on a computer | | Safari on a Mac or iPad | As on the web | Not held | | Safari on an iPhone | Safari keeps its bars | Not held | | Android app | From the moment your app opens | Held | | `dev` | Not shown | A landscape app gets a wide pane | ### Sharing and links | Where | `tinylib.places.share` | A link to another site | | --- | --- | --- | | Web and Safari | The device's share sheet where the browser has one, or Tinylib's own | Opens in a new tab | | Android app | Android's share sheet | Opens in the phone's browser | | `dev` | Tinylib's own sheet | Opens in a new tab | ## tinylib > The global object your client calls Tinylib through. Source: https://tinylib.app/docs/api/tinylib.md `tinylib` is a global in every page of your app, ready with the user's data before your own scripts run. It exists only inside Tinylib and at the address `npx tinylib-sdk dev` prints. The `tinylib-sdk` package declares its type, and [`Register`](https://tinylib.app/docs/api/types.md#register) adds your app's data and room types to it. | Name | What it is | | --- | --- | | [`tinylib.user`](https://tinylib.app/docs/api/tinylib.md#user) | The person using your app. | | [`tinylib.connection`](https://tinylib.app/docs/api/tinylib.md#connection) | Whether the device can reach Tinylib's servers. | | [`tinylib.now()`](https://tinylib.app/docs/api/tinylib.md#now) | Returns the current time on Tinylib's clock, in milliseconds since the Unix epoch. | | [`tinylib.places`](https://tinylib.app/docs/api/places.md) | Read and change the current screen's address, and share a link to it. | | [`tinylib.data`](https://tinylib.app/docs/api/data.md) | Read and write the user's data, and watch it change. | | [`tinylib.friends`](https://tinylib.app/docs/api/friends.md) | List the user's friends who use your app, and read the data they share. | | [`tinylib.rooms`](https://tinylib.app/docs/api/rooms.md) | Create and join rooms from your client, and act in them. | | [`tinylib.invitations`](https://tinylib.app/docs/api/invitations.md) | Show the user's invitations to rooms in your app instead of Tinylib's sheet. | | [`tinylib.reminders`](https://tinylib.app/docs/api/reminders.md) | Schedule, list, and cancel the user's reminders. | | [`tinylib.block()`](https://tinylib.app/docs/api/tinylib.md#block) | Opens Tinylib's [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) for the user to confirm blocking `userId`. | | [`tinylib.report()`](https://tinylib.app/docs/api/tinylib.md#report) | Opens Tinylib's sheet for the user to report `userId` to Tinylib. | ### `tinylib.user` ```ts interface User ``` The person using your app. Properties: - `readonly id: string`: The user's id in your app. The same person has a different id in every app. - `readonly name: string`: The user's name. It can change and isn't unique, so tell users apart by `id`. - `readonly settings: { readonly notifications: boolean; readonly reminders: boolean }`: The user's switches for your app in Tinylib, which aren't the phone's permission to show notifications. `notifications` is the **Rooms** switch, for what your rules send with `ctx.notify`, and `reminders` is the **Reminders** switch. Both are true by default. ```js document.querySelector('#remind-me').hidden = !tinylib.user.settings.reminders ``` - `readonly guest: boolean`: True when the user is a guest: someone without a Tinylib account, known only on the device they use. A guest has data, reminders and every room call, with the same id in every room of your app. A guest gets no notifications or invitations, and can't invite anyone, add friends, or report: [Guests](https://tinylib.app/docs/guide/rooms.md#guests) lists the calls that reject with `refused` for one. Methods: - [`onChange(fn)`](https://tinylib.app/docs/api/tinylib.md#user-onchange): Calls `fn` after `name` or `settings` changes. - [`manageSettings()`](https://tinylib.app/docs/api/tinylib.md#user-managesettings): Opens Tinylib's [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) where the user turns your app's switches on and off. #### `tinylib.user.onChange()` ```ts tinylib.user.onChange(fn: () => void): () => void ``` Calls `fn` after `name` or `settings` changes. ```js tinylib.user.onChange(() => { document.querySelector('#name').textContent = tinylib.user.name }) ``` Parameters: - `fn: () => void`: Called with no arguments. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. #### `tinylib.user.manageSettings()` ```ts tinylib.user.manageSettings(): Promise<{ notifications: boolean; reminders: boolean }> ``` Opens Tinylib's [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) where the user turns your app's switches on and off. When your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md) lists neither `"notifications"` nor `"reminders"`, no sheet opens and the call resolves with `settings` at once. For a guest, Tinylib offers signing in instead, and the call resolves with `settings` unchanged. ```js const turnOn = document.querySelector('#turn-on-reminders') turnOn.addEventListener('click', async () => { try { const settings = await tinylib.user.manageSettings() turnOn.hidden = settings.reminders } catch (error) { toast(error.message) } }) ``` Returns `Promise<{ notifications: boolean; reminders: boolean }>`: The user's `settings` once the sheet closes. Rejects with `TinylibError` codes: - `offline` ### `tinylib.connection` ```ts interface Connection ``` Whether the device can reach Tinylib's servers now. [Errors and offline](https://tinylib.app/docs/guide/errors.md) lists what works without a connection. While a room your app has open can't reach Tinylib, Tinylib shows so over your app, so your app doesn't show its own. Properties: - `readonly online: boolean`: True while the device can reach Tinylib's servers. Methods: - [`onChange(fn)`](https://tinylib.app/docs/api/tinylib.md#connection-onchange): Calls `fn` after `online` changes. #### `tinylib.connection.onChange()` ```ts tinylib.connection.onChange(fn: () => void): () => void ``` Calls `fn` after `online` changes. ```js tinylib.connection.onChange(() => { document.querySelector('#new-game').disabled = !tinylib.connection.online }) ``` Parameters: - `fn: () => void`: Called with no arguments. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. ### `tinylib.now()` ```ts tinylib.now(): number ``` Returns the current time on Tinylib's clock, in milliseconds since the Unix epoch. Tinylib's servers and `ctx.now` in the rules use the same clock. `Date.now()` uses the device's clock, which can be wrong. ```js await tinylib.reminders.set('break', { title: 'Back to work', at: tinylib.now() + 15 * 60_000 }) ``` Returns `number`: The time in milliseconds. ### `tinylib.block()` ```ts tinylib.block(userId: string): Promise ``` Opens Tinylib's [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) for the user to confirm blocking `userId`. [Let users block and report](https://tinylib.app/docs/guide/friends.md#let-users-block-and-report) says when your app must offer it. A block keeps the two from reaching each other again, in every app: neither can invite the other, and neither can join a room the other is in. Rooms they're already in go on unchanged. Works for a guest. ```js if (await tinylib.block(player.id)) tinylib.places.go('') ``` Parameters: - `userId: string`: The id of someone the user has met in your app: someone who has been in a room of your app with them, who invited them to one or was invited by them, or a friend, when your app's `uses` has `"friends"`. Returns `Promise`: True if the user blocked them, or false if they canceled. Rejects with `TinylibError` codes: - `not_found`: When `userId` isn't someone the user has met in your app. - `refused`: When `userId` is the user's own. - `offline` ### `tinylib.report()` ```ts tinylib.report(userId: string): Promise ``` Opens Tinylib's sheet for the user to report `userId` to Tinylib. ```js await tinylib.report(player.id) ``` Parameters: - `userId: string`: The id of someone the user has [met in your app](https://tinylib.app/docs/api/tinylib.md#block), such as a member of one of their rooms. Returns `Promise`: True if the user sent a report, or false if they canceled. Rejects with `TinylibError` codes: - `not_found`: When `userId` isn't someone the user has met in your app. - `refused`: When `userId` is the user's own, or the user is a guest. - `offline` ### `TinylibError` ```ts interface TinylibError extends Error ``` The error a failed call rejects with, or a synchronous call throws: an `Error` whose `name` is `'TinylibError'`, with a `code` and a `message` you can show the user. There's no class to test with `instanceof`, so check `code`. Extends `Error`. Besides the codes each call's entry lists, any call that returns a promise can reject with `failed` when Tinylib's servers or the device fail. A mistake in your own code, such as an argument of the wrong type, is a plain `TypeError` instead, with no `code`. ```js try { await room.act('move', 4) } catch (error) { if (error.code !== 'refused') throw error toast(error.message) } ``` Properties: - `code: ErrorCode`: What went wrong, as one of the [`ErrorCode`](https://tinylib.app/docs/api/tinylib.md#errorcode) values. ### `ErrorCode` ```ts type ErrorCode = | 'refused' | 'failed' | 'offline' | 'limit' | 'not_member' | 'ended' | 'not_found' ``` The `code` of a [`TinylibError`](https://tinylib.app/docs/api/tinylib.md#tinyliberror). | Code | What happened | What to do | | --- | --- | --- | | `refused` | Tinylib or your rules said no, and `message` says why: the rules called `ctx.refuse`, the call needs a Tinylib account and the user has none, your app's `uses` lacks what the call needs, or one person has blocked the other. | Show `message`. | | `failed` | Something went wrong that the user didn't cause. Your rules threw or have no handler for the input, or Tinylib's servers or the device failed. | Show `message`. When your rules caused it, your [developer page](https://tinylib.app/developer) shows the error; otherwise, trying again later may work. | | `offline` | The call needs Tinylib's servers, and the device has no connection. | Wait for `tinylib.connection.onChange`, then let the user try again. A request already on its way may have [arrived](https://tinylib.app/docs/guide/errors.md#when-a-request-loses-its-connection). | | `limit` | The call would pass one of the [limits](https://tinylib.app/docs/reference/limits.md). | Show `message`. A rate allows more later, and a size or a count allows more once something is removed. | | `not_member` | The user isn't a member of the room, or no room has that id. Tinylib doesn't say which. | Take the user out of the room's screen. | | `ended` | The room has ended, or the user deleted their data in your app. | Take the user out of the room's screen. | | `not_found` | No short code, link, invitation, or person matches what the call was given. | Ask the user to check the code or link they used. | ## tinylib.data > Read and write the user's data, and watch it change. Source: https://tinylib.app/docs/api/data.md Tinylib keeps the user's data on the device as keys with JSON values, and syncs it to the user's other devices. Every call is synchronous and works offline. [Data](https://tinylib.app/docs/guide/data.md) explains syncing and the keys only your rules write. ### `tinylib.data.get()` ```ts tinylib.data.get(key: K): DataValue | undefined ``` Returns the value under `key`. ```js const best = tinylib.data.get('best') ?? 0 ``` Parameters: - `key: K`: The key to read. Returns [`DataValue | undefined`](https://tinylib.app/docs/api/types.md#datavalue): The value, or `undefined` when there's none. Throws `TypeError`: - When `key` is empty or isn't a string. ### `tinylib.data.set()` ```ts tinylib.data.set(key: K, value: DataValue, options?: { audience?: Audience }): void ``` Saves `value` under `key` on this device and syncs it to the user's other devices. It doesn't call this client's `onChange` unless the save later fails, and then `onChange` receives the error. ```js tinylib.data.set('best', 42, { audience: 'friends' }) ``` Parameters: - `key: K`: The key to save under. - `value: DataValue`: Any value JSON can represent. - `options?: { audience?: Audience }` - `options.audience?: Audience`: Who can read the value: `'me'`, the user alone, or `'friends'`, the user and their friends who use your app, through [`tinylib.friends.data`](https://tinylib.app/docs/api/friends.md#data). Without it, the value keeps the audience it had, or `'me'` for a new key. `'friends'` needs `"friends"` in your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md); without it, the save fails with `refused`. Throws `TypeError`: - When `key` is empty, isn't a string, is over 128 bytes, or is declared in [`rulesKeys`](https://tinylib.app/docs/reference/tinylib-json.md). - When `value` can't be represented as JSON. - When `audience` isn't `'me'` or `'friends'`. Throws `TinylibError` codes: - `limit`: When the user's data your client writes would grow past 1 MB. ### `tinylib.data.remove()` ```ts tinylib.data.remove(key: DataKey): void ``` Removes the value under `key` and syncs the removal. Does nothing when there's no value. ```js tinylib.data.remove('draft') ``` Parameters: - `key: DataKey`: The key to remove. Throws `TypeError`: - When `key` is empty, isn't a string, or is declared in [`rulesKeys`](https://tinylib.app/docs/reference/tinylib-json.md). ### `tinylib.data.entries()` ```ts tinylib.data.entries(prefix?: string): [DataKey, unknown][] ``` Returns the `[key, value]` pairs whose keys start with `prefix`. ```js const entries = tinylib.data.entries('entry/') ``` Parameters: - `prefix?: string`: The start of the keys to return. Without it, returns every pair. Returns [`[DataKey, unknown][]`](https://tinylib.app/docs/api/types.md#datakey): The pairs, sorted by key. Throws `TypeError`: - When `prefix` isn't a string. ### `tinylib.data.keys()` ```ts tinylib.data.keys(prefix?: string): DataKey[] ``` Returns the keys that start with `prefix`. ```js const drafts = tinylib.data.keys('draft/') ``` Parameters: - `prefix?: string`: The start of the keys to return. Without it, returns every key. Returns [`DataKey[]`](https://tinylib.app/docs/api/types.md#datakey): The keys, sorted. Throws `TypeError`: - When `prefix` isn't a string. ### `tinylib.data.values()` ```ts tinylib.data.values(prefix?: string): unknown[] ``` Returns the values under the keys that start with `prefix`. ```js const texts = tinylib.data.values('entry/') ``` Parameters: - `prefix?: string`: The start of the keys whose values to return. Without it, returns every value. Returns `unknown[]`: The values, sorted by key. Throws `TypeError`: - When `prefix` isn't a string. ### `tinylib.data.onChange()` ```ts tinylib.data.onChange(fn: (keys: string[], error?: { code: ErrorCode; message: string }) => void): () => void ``` Calls `fn` after the user's other tabs or devices, or your rules, change values, and after a save this client made fails. A failed key goes back to the value on Tinylib's servers, unless a later save to it is still waiting to be sent. Keep what the user typed on screen if they should try again. A failed save's `error.code` is one of: - `limit`: the user's data, with writes from their other devices, would pass 1 MB, or this device is out of space. - `refused`: the value was saved with `audience: 'friends'`, and your app's `uses` lacks `"friends"`. - `failed`: this device or Tinylib's servers couldn't save it. ```js tinylib.data.onChange((keys, error) => { if (error) toast(error.message) drawEntries() }) ``` Parameters: - `fn: (keys: string[], error?: { code: ErrorCode; message: string }) => void`: Called with `keys`, the keys that changed, and, when a save failed, `error`, with the `code` and `message` of a [`TinylibError`](https://tinylib.app/docs/api/tinylib.md#tinyliberror). Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. ## tinylib.rooms > Create and join rooms from your client, and act in them. Source: https://tinylib.app/docs/api/rooms.md [Rooms and rules](https://tinylib.app/docs/guide/rooms.md) explains rooms. ### `tinylib.rooms.create()` ```ts tinylib.rooms.create(kind: RoomKind, options?: unknown, how?: { rematchOf?: string }): Promise<{ id: string }> ``` Creates a room of kind `kind`, with the user as its first member. ```js const { id } = await tinylib.rooms.create('game') const { id: rematchId } = await tinylib.rooms.create('game', {}, { rematchOf: id }) ``` Parameters: - `kind: RoomKind`: The name of one of `rules.js`'s exports. - `options?: unknown`: Any value JSON can represent. The kind's `create` receives it as its first argument. - `how?: { rematchOf?: string }` - `how.rematchOf?: string`: The id of a room the user is still in, ended or not. When the new room's `canInvite` lets the user invite, Tinylib invites that room's other members, except guests, and marks each invitation with `rematchOf`. Returns `Promise<{ id: string }>`: The new room's id, as `{ id }`. Rejects with `TypeError`: - When `options` can't be represented as JSON, or `rematchOf` isn't a string. Rejects with `TinylibError` codes: - `refused`: When the kind's `create` refuses, the user is a guest and the kind has `guests: false`, or the user is a guest and `rematchOf` is given. - `not_member`: When the user isn't a member of the room `rematchOf` names. - `limit`: When the user is already in 50 rooms of your app, has created rooms faster than 60 per hour in your app, `options` is over 16 KB, or the state `create` returns is past the room's [limits](https://tinylib.app/docs/reference/limits.md#rooms). - `failed`: When `kind` isn't an export of `rules.js`, or the kind's `create` throws. - `offline` ### `tinylib.rooms.open()` ```ts tinylib.rooms.open(id: string): Promise> ``` Opens the room in this client and connects the user to it. Most apps call [`tinylib.rooms.follow`](https://tinylib.app/docs/api/rooms.md#follow) instead, which opens the room and keeps it open. Opening a room right after this client made it with `create` or joined it with `join` or an invitation resolves at once, with the view that call brought, and connects behind it. A `room.act` made meanwhile waits for the connection, and rejects with `offline` if it doesn't come. Any other room opens once it's connected. Without a connection, a room of an offline kind opens from the copy this device saved when it last had the room open, and connects once the device is back online. A room of any other kind opens once the device is back online. While the room is open in any client of the user's, `ctx.notify` from the room doesn't reach them on any device. The room stays open until `room.close()`, until the user leaves your app, or until the device loses its connection. A tab out of view for a minute lets go of the connection, and connects again once it's back in view. ```js const room = await tinylib.rooms.open(id) ``` Parameters: - ` extends RoomKind`: The room's kind, which types its view and events. Default: `RoomKind`. - `id: string`: The room's id. Returns [`Promise>`](https://tinylib.app/docs/api/rooms.md#openroom): The open room. Rejects with `TinylibError` codes: - `not_member`: When the user isn't a member of the room, or no room has the id `id`. - `ended`: When the room has ended. - `limit`: When this client already has 10 rooms open. Close the ones you no longer show. - `offline`: Unless the room is of an offline kind this device has had open. Caveats: - Moving to another address doesn't close the room. Call `room.close()` when you stop showing it. ### `tinylib.rooms.follow()` ```ts tinylib.rooms.follow(id: string, onView: (following: Following) => void): () => void ``` Opens the room and keeps it open, calling `onView` with each new view and each change to `pending`. Without a connection, a room of an offline kind opens from the copy this device saved when it last had the room open. Any other room opens once the device is back online, and `onView` isn't called until then. When the room ends or the user leaves it, `onView` gets a last call with `room.ended` or `room.left` true. When the room can't be opened, `onView` gets one call with `error`. Either way, the room closes and `onView` isn't called again. Until you call the function `follow` returns, the room is open, so `ctx.notify` doesn't reach the user. `follow` doesn't pass events: call `room.onEvent` on the first call without an `error`. ```js const stop = tinylib.rooms.follow(id, ({ view, room, error }) => { if (error) toast(error.message) else if (room.left) tinylib.places.go('') else if (view) drawBoard(view, room.ended) }) ``` Parameters: - ` extends RoomKind`: The room's kind, which types its views. Default: `RoomKind`. - `id: string`: The room's id. - `onView: (following: Following) => void`: Called with a [`Following`](https://tinylib.app/docs/api/rooms.md#following): the latest `view`, the open `room`, and any `error`. Returns `() => void`: A function that closes the room and stops the calls. You can call it from inside `onView`. Throws `TypeError`: - When `onView` isn't a function. ### `tinylib.rooms.join()` ```ts tinylib.rooms.join(code: string): Promise<{ id: string }> ``` Joins the room whose short code is `code`. ```js const { id } = await tinylib.rooms.join('K7QD2M') ``` Parameters: - `code: string`: A code from [`tinylib.rooms.getLink`](https://tinylib.app/docs/api/rooms.md#getlink). Case and spaces around it don't matter. Returns `Promise<{ id: string }>`: The room's id, as `{ id }`. Rejects with `TinylibError` codes: - `not_found`: When the code doesn't let the user into a room of your app now. - `refused`: When the user or a member has blocked the other, or the user is a guest and the kind has `guests: false`. - `limit`: When the user is already in 50 rooms of your app, has tried codes faster than 10 per minute in your app, or the room already has 64 members. - `offline` ### `tinylib.rooms.invite()` ```ts tinylib.rooms.invite(id: string, userId?: string): Promise ``` Invites `userId` to the room and resolves once the invitation is sent. Without `userId`, it opens Tinylib's invite sheet, where the user picks any of their friends, with or without your app, or shares the room's link. It then resolves when the sheet closes, without saying who was invited. ```js await tinylib.rooms.invite(id, friend.id) ``` Parameters: - `id: string`: The room's id. - `userId?: string`: The id of someone the user has [met in your app](https://tinylib.app/docs/api/tinylib.md#block), such as a friend from [`tinylib.friends.list`](https://tinylib.app/docs/api/friends.md#list) or a member of one of the user's rooms. Rejects with `TinylibError` codes: - `refused`: When the kind's `canInvite` doesn't let the user invite now, `userId` is a guest's or the user's own, or the user is a guest. - `not_found`: When `userId` isn't someone the user has met in your app. - `not_member`: When the user isn't a member of the room. - `ended`: When the room has ended. - `limit`: When the user has sent invitations faster than 60 per hour in your app. - `offline` ### `tinylib.rooms.getLink()` ```ts tinylib.rooms.getLink(id: string, options?: { code?: boolean }): Promise<{ url: string; code: string | null }> ``` Gets the room's link, and its short code if you ask for one. The link and code work while `canInvite` is true for at least one member. Someone who opens the link joins the room, with or without a Tinylib account unless the kind has `guests: false`. Tinylib then opens your app at the kind's `address`. The link changes from time to time, so call `getLink` each time you share it. ```js const { url, code } = await tinylib.rooms.getLink(id, { code: true }) ``` Parameters: - `id: string`: The room's id. - `options?: { code?: boolean }` - `options.code?: boolean`: If true, also makes a short code, which people type into your app for [`tinylib.rooms.join`](https://tinylib.app/docs/api/rooms.md#join). Returns `Promise<{ url: string; code: string | null }>`: `url`, the room's link, and `code`, its short code, or null when nobody has asked for one. Rejects with `TinylibError` codes: - `refused`: When the kind's `canInvite` doesn't let the user invite now. - `not_member`: When the user isn't a member of the room. - `ended`: When the room has ended. - `failed`: When Tinylib couldn't make a short code. - `offline` ### `tinylib.rooms.leave()` ```ts tinylib.rooms.leave(id: string): Promise ``` Removes the user from the room, which runs the kind's `leave` with `'left'`. The room stays in `list()` with `left` true. ```js await tinylib.rooms.leave(id) ``` Parameters: - `id: string`: The room's id. Rejects with `TinylibError` codes: - `ended`: When the room has ended. - `not_member`: When the user isn't a member of the room, or no room has the id `id`. - `offline` ### `tinylib.rooms.list()` ```ts tinylib.rooms.list(): Promise ``` Lists the user's rooms in your app, including those that ended or that the user left. Offline, it resolves with only the ended and left rooms this device last heard of, so keep the id of a room your app needs offline, for example in `tinylib.data`. ```js const waiting = (await tinylib.rooms.list()).filter((room) => room.waiting) ``` Returns [`Promise`](https://tinylib.app/docs/api/rooms.md#roomlistitem): The rooms, each a [`RoomListItem`](https://tinylib.app/docs/api/rooms.md#roomlistitem): current rooms, oldest first; then ended rooms, latest ended first; then rooms the user left, latest left first. ### `tinylib.rooms.onChange()` ```ts tinylib.rooms.onChange(fn: (list: RoomListItem[]) => void): () => void ``` Calls `fn` with the user's rooms after anything in the list changes, but not at once: call `list()` for the first list. ```js tinylib.rooms.onChange((rooms) => { drawRooms(rooms.filter((room) => !room.ended && !room.left)) }) ``` Parameters: - `fn: (list: RoomListItem[]) => void`: Called with the rooms, in the order `list()` gives. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. ### `tinylib.rooms.data()` ```ts tinylib.rooms.data(roomId: string): RemoteStore ``` Returns a read-only store of the room's data, which the rules write with `ctx.data.room`. A user who left reads it as it was when they left. ```js const moves = await tinylib.rooms.data(id).values('move/') ``` Parameters: - `roomId: string`: The room's id. Returns [`RemoteStore`](https://tinylib.app/docs/api/friends.md#remotestore): A [`RemoteStore`](https://tinylib.app/docs/api/friends.md#remotestore) of the room's data. Its methods reject with `not_member` when the user was never a member of the room. Without a connection, they reject with `offline`, unless the room has ended or the user left it and this device has read its data before. ### `OpenRoom` ```ts interface OpenRoom ``` A room open in this client, as [`tinylib.rooms.open`](https://tinylib.app/docs/api/rooms.md#open) resolves with it. Properties: - `readonly id: string`: The room's id. - `readonly kind: string`: The room's kind: the name of the `rules.js` export it runs. - `readonly view: RoomView | null`: What the kind's `view` last returned for the user, or the whole state in an offline kind. Null until the first view arrives. - `readonly ended: boolean`: True once the room has ended with the user in it. A user who leaves gets `left` instead, even when leaving ends the room. - `readonly left: boolean`: True once the user is no longer a member: they left, or the rules removed them. - `readonly pending: readonly { readonly action: string; readonly data: unknown }[]`: In an offline kind, the user's actions whose view hasn't reached this page yet, oldest first, each with the `action` and `data` passed to `act`. One leaves once `view` includes it, or once the room refuses it. Always empty in other kinds. Draw them over `view`, in a way that's safe to repeat: an action can still be here after `view` includes it. ```js const items = room.view?.items ?? [] const adding = room.pending.filter((p) => p.action === 'add' && !items.some((item) => item.id === p.data.id)) ``` Methods: - [`close()`](https://tinylib.app/docs/api/rooms.md#room-close): Closes the room in this client: views and events stop, and `act` rejects with `failed`. - [`act(action, data?)`](https://tinylib.app/docs/api/rooms.md#room-act): Sends `action`, with `data`, to the rules. - [`relay(data)`](https://tinylib.app/docs/api/rooms.md#room-relay): Sends numbers straight to the room's other members, for live data such as positions and aim. - [`onRelay(fn)`](https://tinylib.app/docs/api/rooms.md#room-onrelay): Calls `fn` with each relay another member sends, with their id and when they sent it. - [`onView(fn)`](https://tinylib.app/docs/api/rooms.md#room-onview): Calls `fn` with the current view right after this call, if there is one, and then with each new view. - [`onEvent(fn)`](https://tinylib.app/docs/api/rooms.md#room-onevent): Calls `fn` with each event the rules send the user with `ctx.emit`, after the view from the same input. - [`onChange(fn)`](https://tinylib.app/docs/api/rooms.md#room-onchange): Calls `fn` when `ended`, `left`, or `pending` changes. #### `room.close()` ```ts room.close(): void ``` Closes the room in this client: views and events stop, and `act` rejects with `failed`. The user stays a member, and once no client of theirs has the room open, `ctx.notify` reaches them again. #### `room.act()` ```ts room.act(action: string, data?: unknown): Promise ``` Sends `action`, with `data`, to the rules. With a connection, it resolves once the rules accept the action, after this client has the views and events it caused. In an offline kind, it also works without a connection. The action waits in `pending`, the call resolves once the device has saved it, and Tinylib sends it when the device reconnects. If the rules then refuse it, it leaves `pending` and Tinylib tells the user a change couldn't be saved. An action from a page older than the current [version](https://tinylib.app/docs/guide/publishing.md), online or offline, is refused without running your rules, and the page reloads onto the current version. ```js try { await room.act('move', 4) } catch (error) { if (error.code !== 'refused') throw error toast(error.message) } ``` Parameters: - `action: string`: The name of one of the kind's `actions`. - `data?: unknown`: Any value JSON can represent. The action's handler receives it as its second argument. Rejects with `TypeError`: - When `action` is empty or isn't a string, or `data` can't be represented as JSON. Rejects with `TinylibError` codes: - `refused`: When the rules refuse the action, with the message the rules gave `ctx.refuse`, or when the page is older than the current version. - `limit`: When `data` is over 16 KB, the user has sent the room 30 per second actions this second, or the action would leave the room's state or room data past its [limit](https://tinylib.app/docs/reference/limits.md#rooms). The message is the room's, such as "This room is full." - `not_member`: When the user is no longer a member of the room. - `ended`: When the room has ended. - `offline`: When the device has no connection and the room's kind isn't offline. - `failed`: - When the rules throw or have no action named `action`. Your [developer page](https://tinylib.app/developer) shows the error. - When the room was closed in this client. #### `room.relay()` ```ts room.relay(data: readonly number[]): void ``` Sends numbers straight to the room's other members, for live data such as positions and aim. Every other member with the room open gets them in `onRelay`, with when this client sent them, in the order it sent them, after up to 25 ms in the room, with each number between -10 billion and 10 billion rounded to 5 decimal places. A kind with a `beat` gets them too, at its next beat; otherwise your rules never see a relay. The room keeps none: a member who opens the room later gets none of the earlier ones, and a relay sent before the room has connected, or while the device is reconnecting, is lost. Use `act` for what happens once, such as a hit or a pickup. See [Send live data with a relay](https://tinylib.app/docs/guide/rooms.md#send-live-data-with-a-relay). ```js const sending = setInterval(() => { if (room.ended || room.left) return clearInterval(sending) room.relay([me.x, me.y, me.aim]) }, 50) ``` Parameters: - `data: readonly number[]`: An array of at most 32 finite numbers. Throws `TypeError`: - When `data` isn't an array of finite numbers, or is too long. Throws `TinylibError` codes: - `limit`: When this client has sent 30 per second relays in this room in the last second. - `not_member`: When the user is no longer a member of the room. - `ended`: When the room has ended. - `failed`: When the room was closed in this client. #### `room.onRelay()` ```ts room.onRelay(fn: (from: string, data: number[], at: number) => void): () => void ``` Calls `fn` with each relay another member sends, with their id and when they sent it. Unlike events, relays that arrive while the room has no relay listener are dropped, not held for the next one. ```js room.onRelay((from, [x, y, aim], at) => players.get(from)?.heard({ x, y, aim, at })) ``` Parameters: - `fn: (from: string, data: number[], at: number) => void`: Called with the sender's id, their numbers, and when their client sent them, on the clock [`tinylib.now`](https://tinylib.app/docs/api/tinylib.md#now) reads, up to 100 milliseconds ahead of it. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. #### `room.onView()` ```ts room.onView(fn: (view: RoomView) => void): () => void ``` Calls `fn` with the current view right after this call, if there is one, and then with each new view. ```js room.onView((view) => drawBoard(view)) ``` Parameters: - `fn: (view: RoomView) => void`: Called with the view. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. #### `room.onEvent()` ```ts room.onEvent(fn: (event: RoomEvent) => void): () => void ``` Calls `fn` with each event the rules send the user with `ctx.emit`, after the view from the same input. Events that arrive while the room has no event listener wait for the next one. ```js room.onEvent((event) => playSound(event.sound)) ``` Parameters: - `fn: (event: RoomEvent) => void`: Called with the event. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. #### `room.onChange()` ```ts room.onChange(fn: () => void): () => void ``` Calls `fn` when `ended`, `left`, or `pending` changes. Parameters: - `fn: () => void`: Called with no arguments. Read the room's properties for what changed. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. ### `Following` ```ts interface Following ``` What [`tinylib.rooms.follow`](https://tinylib.app/docs/api/rooms.md#follow) calls its `onView` with. Properties: - `readonly view: RoomView | null`: The room's latest view, the same as `room.view`. - `readonly room: OpenRoom | null`: The open room, or null when `error` is set. - `readonly error: TinylibError | null`: The error [`tinylib.rooms.open`](https://tinylib.app/docs/api/rooms.md#open) would have rejected with, or null. ### `RoomListItem` ```ts interface RoomListItem ``` One of the user's rooms, as `tinylib.rooms.list()` resolves with it. Properties: - `id: string`: The room's id. - `kind: string`: The room's kind: the name of the `rules.js` export it runs. - `name: string | null`: The name the rules set with `ctx.setName`, or null. - `status: string | null`: The user's status text, as the kind's `status` last returned it, or null. - `waiting: boolean`: True when the user's last status said the room is waiting on them. Always false once the room has ended or the user has left. - `members: Member[]`: The room's members, each a [`Member`](https://tinylib.app/docs/api/types.md#member): its current ones, or those it had when it ended or the user left. Leaves out anyone blocked either way, and anyone who has deleted their data. - `ended: boolean`: True once the room has ended. - `closed: boolean`: True when the room ended without the rules calling `ctx.end`: nobody used it for its kind's `idleMs`, or its last member left. - `left: boolean`: True when the user left the room or was removed from it, for example by the rules or by blocking a member. `ended` then stays false, even if the room ends later. - `muted: boolean`: True when the user muted this room's notifications. `ctx.notify` doesn't reach them, but `status` and `waiting` still update. - `endedAt: number | null`: When the room ended, in milliseconds on Tinylib's clock, or null when it hasn't. ## tinylib.invitations > Show the user's invitations to rooms in your app instead of Tinylib's sheet. Source: https://tinylib.app/docs/api/invitations.md While your app is open, Tinylib shows its own [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets) for each invitation to it, except those you take with `handle`. Outside your app, Tinylib notifies the user of every invitation. ### `tinylib.invitations.list()` ```ts tinylib.invitations.list(): Promise ``` Lists the user's invitations to your app's rooms, including those a `handle` shows. ```js const invitations = await tinylib.invitations.list() ``` Returns [`Promise`](https://tinylib.app/docs/api/invitations.md#invitation): The invitations, newest first. Always empty for a guest. Rejects with `TinylibError` codes: - `offline` ### `tinylib.invitations.handle()` ```ts tinylib.invitations.handle(pick: (invitation: Invitation) => boolean, fn: (invitations: Invitation[]) => void): () => void ``` Hands the invitations `pick` selects to your app to show, in place of Tinylib's sheet. Calls `fn` with them at once and again each time the user's invitations to your app change. Offer [block and report](https://tinylib.app/docs/guide/friends.md#let-users-block-and-report) for each sender. ```js const rematch = document.querySelector('#rematch') const stop = tinylib.invitations.handle( (invitation) => invitation.rematchOf === gameId, (invitations) => { rematch.hidden = invitations.length === 0 }, ) ``` Parameters: - `pick: (invitation: Invitation) => boolean`: Called with each invitation. Returns true for one your app shows. - `fn: (invitations: Invitation[]) => void`: Called with every invitation `pick` returned true for, possibly none. Returns `() => void`: A function that stops the calls and gives the invitations back to Tinylib's sheet. Throws `TypeError`: - When `pick` or `fn` isn't a function. ### `Invitation` ```ts interface Invitation ``` An invitation to one of your app's rooms. Properties: - `readonly id: string`: The invitation's id. - `readonly from: Member`: The member who sent the invitation, a [`Member`](https://tinylib.app/docs/api/types.md#member). - `readonly kind: string`: The room's kind. - `readonly name: string | null`: The room's name, or null when the rules haven't set one. - `readonly at: number`: When the invitation was sent, in milliseconds on Tinylib's clock. - `readonly rematchOf: string | null`: The id of the room this one is a rematch of, or null. Methods: - [`accept()`](https://tinylib.app/docs/api/invitations.md#invitation-accept): Joins the room and removes the invitation. - [`dismiss()`](https://tinylib.app/docs/api/invitations.md#invitation-dismiss): Declines the invitation and removes it. #### `invitation.accept()` ```ts invitation.accept(): Promise<{ id: string }> ``` Joins the room and removes the invitation. It doesn't open the room. ```js const { id } = await invitation.accept() tinylib.places.go(`game/${id}`) ``` Returns `Promise<{ id: string }>`: The room's id, as `{ id }`. Rejects with `TinylibError` codes: - `not_found`: When the invitation is gone, or its sender has left the room or may no longer invite. - `refused`: When the user or a member has blocked the other, or the user is a guest. - `limit`: When the user is already in 50 rooms of your app, or the room already has 64 members. - `offline` #### `invitation.dismiss()` ```ts invitation.dismiss(): Promise ``` Declines the invitation and removes it. ```js await invitation.dismiss() ``` Rejects with `TinylibError` codes: - `refused`: When the user is a guest. - `offline` ## tinylib.friends > List the user's friends who use your app, and read the data they share. Source: https://tinylib.app/docs/api/friends.md Your app sees the user's friends who use it. It can't add or remove friends; people do that in Tinylib. Reading friends or their values needs `"friends"` in your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses). ### `tinylib.friends.list()` ```ts tinylib.friends.list(): Promise ``` Lists the user's friends who use your app, leaving out anyone blocked either way. ```js const friends = await tinylib.friends.list() ``` Returns [`Promise`](https://tinylib.app/docs/api/types.md#member): The friends, sorted by name, each a [`Member`](https://tinylib.app/docs/api/types.md#member) with `guest` false. Rejects with `TinylibError` codes: - `refused`: When your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md) lacks `"friends"`, or the user is a guest. - `offline` ### `tinylib.friends.sendApp()` ```ts tinylib.friends.sendApp(): Promise ``` Opens Tinylib's send sheet, where the user picks any of their friends to send your app to. Resolves when the sheet closes, without saying who was picked. Rejects with `TinylibError` codes: - `refused`: When the user is a guest. - `offline` ### `tinylib.friends.data` ```ts interface FriendsData ``` Reads the values the user's friends saved with `audience: 'friends'`. Nothing tells your client when they change, so read them each time you show them. Methods: - [`get(key)`](https://tinylib.app/docs/api/friends.md#data-get): Reads every friend's value under `key`. - [`of(friendId)`](https://tinylib.app/docs/api/friends.md#data-of): Returns a read-only store of the values one friend saved with `audience: 'friends'`. #### `tinylib.friends.data.get()` ```ts tinylib.friends.data.get(key: string): Promise> ``` Reads every friend's value under `key`. ```js const best = await tinylib.friends.data.get('best') ``` Parameters: - `key: string`: The key to read. Returns [`Promise>`](https://tinylib.app/docs/api/friends.md#friendvalue): A `Map` from each friend's id to their [`FriendValue`](https://tinylib.app/docs/api/friends.md#friendvalue). Friends with no value under `key` are left out. Rejects with `TinylibError` codes: - `refused`: When your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md) lacks `"friends"`, or the user is a guest. - `offline` #### `tinylib.friends.data.of()` ```ts tinylib.friends.data.of(friendId: string): RemoteStore ``` Returns a read-only store of the values one friend saved with `audience: 'friends'`. The store is empty for anyone who isn't the user's friend. ```js const days = await tinylib.friends.data.of(friend.id).entries('day/') // [['day/2026-10-20', { value: 280, changedAt: 1792540800000 }], …] ``` Parameters: - `friendId: string`: The friend's id, from [`tinylib.friends.list`](https://tinylib.app/docs/api/friends.md#list). Returns `RemoteStore`: A [`RemoteStore`](https://tinylib.app/docs/api/friends.md#remotestore) of the friend's values, each a [`FriendValue`](https://tinylib.app/docs/api/friends.md#friendvalue). Its methods reject with `refused` when your app's `uses` lacks `"friends"` or the user is a guest, and with `offline` without a connection. ### `FriendValue` ```ts interface FriendValue ``` A value a friend saved with `audience: 'friends'`, and when it last changed. Properties: - `value: unknown`: The value, as the friend saved it. - `changedAt: number`: When the value last changed, in milliseconds on Tinylib's clock. ### `RemoteStore` ```ts interface RemoteStore ``` A read-only store with the read methods of `tinylib.data`, reading from Tinylib's servers. The call that returned the store lists what its methods reject with. Methods: - [`get(key)`](https://tinylib.app/docs/api/friends.md#remotestore-get): Resolves with the value under `key`, or `undefined` when there's none. - [`keys(prefix?)`](https://tinylib.app/docs/api/friends.md#remotestore-keys): Resolves with the keys that start with `prefix`, or every key without it, sorted. - [`values(prefix?)`](https://tinylib.app/docs/api/friends.md#remotestore-values): Resolves with the values under the keys that start with `prefix`, or every value without it, sorted by key. - [`entries(prefix?)`](https://tinylib.app/docs/api/friends.md#remotestore-entries): Resolves with the `[key, value]` pairs whose keys start with `prefix`, or every pair without it, sorted by key. #### `RemoteStore.get()` ```ts RemoteStore.get(key: string): Promise ``` Resolves with the value under `key`, or `undefined` when there's none. #### `RemoteStore.keys()` ```ts RemoteStore.keys(prefix?: string): Promise ``` Resolves with the keys that start with `prefix`, or every key without it, sorted. #### `RemoteStore.values()` ```ts RemoteStore.values(prefix?: string): Promise ``` Resolves with the values under the keys that start with `prefix`, or every value without it, sorted by key. #### `RemoteStore.entries()` ```ts RemoteStore.entries(prefix?: string): Promise<[string, V][]> ``` Resolves with the `[key, value]` pairs whose keys start with `prefix`, or every pair without it, sorted by key. ## tinylib.places > Read and change the current screen's address, and share a link to it. Source: https://tinylib.app/docs/api/places.md Each screen of your app has an [address](https://tinylib.app/docs/guide/places.md): the URL's path without its leading `/`, plus its query string. ### `tinylib.places.address` ```ts tinylib.places.address: string ``` The address the user is on now, such as `game/abc?tab=moves`, or `''` on the start screen. ### `tinylib.places.go()` ```ts tinylib.places.go(address: string, options?: { replace?: boolean }): void ``` Moves the user to `address` without loading a new page. Before `go` returns, `address` is the new address and `onChange` functions have run. After a tap, back returns the user to the screen they left. A move with no tap, such as a redirect, takes the current screen's place, so back never returns to a screen that would only send them away again. A move to your start screen goes back to it when it's beneath, and otherwise takes the current screen's place. ```js tinylib.places.go(`game/${id}`) ``` Parameters: - `address: string`: The address to move to, without a leading `/`. - `options?: { replace?: boolean }` - `options.replace?: boolean`: If true, takes the current screen's place even after a tap. Throws `TypeError`: - When `address` isn't a string, or starts with `/`. ### `tinylib.places.exit()` ```ts tinylib.places.exit(): Promise ``` Leaves your app for the user's Library. Draw your own Exit button and call this from its tap. It's optional: back leaves your app from the screen it opened at. ```js exitButton.onclick = () => tinylib.places.exit() ``` Rejects with `TinylibError` codes: - `refused`: With no tap in your app, or while one of Tinylib's sheets is open. ### `tinylib.places.onChange()` ```ts tinylib.places.onChange(fn: () => void): () => void ``` Calls `fn` each time the address is set, even to the one the user is already on, whether by `go`, a link, a router's `history.pushState` or `replaceState`, the back and forward buttons, or Tinylib, as when the user joins a room from its invitation sheet. `fn` isn't called for the address your app opens at, so read `address` once at load. ```js tinylib.places.onChange(() => route(tinylib.places.address)) ``` Parameters: - `fn: () => void`: Called with no arguments. Read `address` for the new address. Returns `() => void`: A function that stops the calls. Throws `TypeError`: - When `fn` isn't a function. ### `tinylib.places.share()` ```ts tinylib.places.share(address: string, text?: string): Promise ``` Opens the share sheet with `text` and a link that opens your app at `address`. The link lets no one into a room: share [`tinylib.rooms.getLink`](https://tinylib.app/docs/api/rooms.md#getlink) for that. Works offline and for a guest. ```js await tinylib.places.share(`list/${id}`, 'Our shopping list') ``` Parameters: - `address: string`: The address to link to, without a leading `/`. - `text?: string`: A message to send with the link. Rejects with `TypeError`: - When `address` isn't a string. ## tinylib.reminders > Schedule, list, and cancel the user's reminders. Source: https://tinylib.app/docs/api/reminders.md Your app schedules reminders for its own user, outside any room, and Tinylib shows each one as a notification when it's due. Each reminder has a name, unique to the user in your app. Every call works offline, and Tinylib syncs the reminders to the user's other devices. [Platforms](https://tinylib.app/docs/guide/platforms.md#notifications-and-reminders) says how reminders go off on each device. Setting a reminder needs `"reminders"` in your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses). ### `tinylib.reminders.set()` ```ts tinylib.reminders.set(name: string, options: ReminderOptions): Promise ``` Sets the reminder `name`, replacing any reminder with that name, and resolves once this device has saved it. While your app's **Reminders** switch is off, the reminder is kept but doesn't go off. ```js await tinylib.reminders.set('evening', { title: 'Journal', repeat: tinylib.reminders.daily('18:00') }) ``` Parameters: - `name: string`: The reminder's name, up to 64 characters. - `options: ReminderOptions`: What the reminder says, when it goes off, and where it opens. Rejects with `TypeError`: - When `name` or `title` is empty, `options` doesn't have exactly one of `at` and `repeat`, `at` is in the wrong form, `address` starts with `/`, or a field has the wrong type. Rejects with `TinylibError` codes: - `refused`: - When `name`, `title`, `text`, or `address` is too long, or `at` is a date that doesn't exist, such as `'2026-02-30T10:00'`. - When your app's [`uses`](https://tinylib.app/docs/reference/tinylib-json.md) lacks `"reminders"`. - `limit`: When the user already has 32 of your app's reminders. Setting a name already in use doesn't count again. - `ended`: When the user deleted their data in your app. Caveats: - An `at` that has already passed sets nothing, cancels any reminder with that name, and still resolves. - A repeating reminder first goes off at the next time its schedule matches, and can't be set to start later. ### `tinylib.reminders.daily()` ```ts tinylib.reminders.daily(time: string): Repeat ``` Makes a schedule for every day at `time`. ```js tinylib.reminders.daily('18:00') // { every: 'day', time: '18:00' } ``` Parameters: - `time: string`: `'HH:MM'` on a 24-hour clock, in the device's time zone. Returns [`Repeat`](https://tinylib.app/docs/api/reminders.md#repeat): The schedule, for `repeat` in [`tinylib.reminders.set`](https://tinylib.app/docs/api/reminders.md#set). Throws `TypeError`: - When `time` isn't `'HH:MM'`. ### `tinylib.reminders.weekly()` ```ts tinylib.reminders.weekly(days: Day[], time: string): Repeat ``` Makes a schedule for each of `days` at `time`. ```js tinylib.reminders.weekly(['mon', 'tue', 'wed', 'thu', 'fri'], '09:30') ``` Parameters: - `days: Day[]`: The days of the week, each once, from `'mon'` to `'sun'`. - `time: string`: `'HH:MM'` on a 24-hour clock, in the device's time zone. Returns [`Repeat`](https://tinylib.app/docs/api/reminders.md#repeat): The schedule, for `repeat` in [`tinylib.reminders.set`](https://tinylib.app/docs/api/reminders.md#set). Throws `TypeError`: - When `days` is empty, lists a day twice, or holds something that isn't a day, or when `time` isn't `'HH:MM'`. ### `tinylib.reminders.monthly()` ```ts tinylib.reminders.monthly(day: number, time: string): Repeat ``` Makes a schedule for day `day` of every month at `time`. In a month without that day, the reminder goes off on the month's last day. ```js tinylib.reminders.monthly(31, '09:00') // the last day of every month ``` Parameters: - `day: number`: The day of the month, from 1 to 31. - `time: string`: `'HH:MM'` on a 24-hour clock, in the device's time zone. Returns [`Repeat`](https://tinylib.app/docs/api/reminders.md#repeat): The schedule, for `repeat` in [`tinylib.reminders.set`](https://tinylib.app/docs/api/reminders.md#set). Throws `TypeError`: - When `day` isn't an integer from 1 to 31, or `time` isn't `'HH:MM'`. ### `tinylib.reminders.list()` ```ts tinylib.reminders.list(): Promise ``` Lists the user's reminders in your app that will go off again. The user can cancel reminders from your app's page in Tinylib, so read the list each time you show it. ```js const evening = (await tinylib.reminders.list()).find((reminder) => reminder.name === 'evening') ``` Returns [`Promise`](https://tinylib.app/docs/api/reminders.md#reminder): The reminders, each a [`Reminder`](https://tinylib.app/docs/api/reminders.md#reminder). ### `tinylib.reminders.cancel()` ```ts tinylib.reminders.cancel(name: string): Promise ``` Cancels the reminder `name`. Resolves without doing anything when there's none. ```js await tinylib.reminders.cancel('evening') ``` Parameters: - `name: string`: The reminder's name. ### `Reminder` ```ts interface Reminder ``` One of the user's reminders, as `tinylib.reminders.list()` resolves with it. Properties: - `name: string`: The name it was set under. - `title: string`: The notification's title. - `text: string | null`: The notification's text, or null when it has none. - `address: string`: The address your app opens at when the user taps the reminder. `''` is the start screen. - `at: string | number | null`: For a one-time reminder, the `at` it was set with: a local date and time such as `'2026-10-06T18:00'`, or milliseconds on Tinylib's clock. Null for a repeating reminder. - `repeat: Repeat | null`: For a repeating reminder, its schedule. Null for a one-time reminder. - `timeZone: string`: The IANA time zone the reminder was set in, such as `'Europe/London'`. - `next: number | null`: When the reminder next goes off, in milliseconds on Tinylib's clock, or null once it won't go off again. ### `ReminderOptions` ```ts interface ReminderOptions ``` What a reminder says, when it goes off, and where it opens. Give exactly one of `at` and `repeat`. Properties: - `title: string`: The notification's title, up to 80 characters. - `text?: string`: The notification's text, up to 200 characters. - `at?: string | number`: For a one-time reminder, when it goes off: a local date and time, `'YYYY-MM-DDTHH:MM'` with optional `:SS`, in the device's time zone, or milliseconds on Tinylib's clock, such as `tinylib.now()` plus a delay. - `repeat?: Repeat`: For a repeating reminder, its schedule, made with `daily`, `weekly`, or `monthly`. - `address?: string`: The address your app opens at when the user taps the reminder, without a leading `/`, up to 100 characters. Defaults to `''`, the start screen. ### `Repeat` ```ts type Repeat = | { every: 'day'; time: string } | { every: 'week'; days: Day[]; time: string } | { every: 'month'; day: number; time: string } ``` A repeating reminder's schedule, made with `tinylib.reminders.daily`, `weekly`, or `monthly`. `time` is `'HH:MM'` in the time zone where the reminder was set, wherever the user goes. ### `Day` ```ts type Day = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' ``` A day of the week, for `tinylib.reminders.weekly`. ## Types > TypeScript types for your app's data and rooms. Source: https://tinylib.app/docs/api/types.md ### `Register` ```ts interface Register ``` Declares your app's data and room types, so the calls of `tinylib` use them. `data` maps each key, or key template, to its value's type. `rooms` maps each kind to the type its `view` returns and the type of the events its rules emit. Declare it in a file with an import or export, such as the `import type` line below. In a file with neither, `declare module 'tinylib-sdk'` replaces the package's types instead of adding to them. ```ts tinylib-env.d.ts import type {} from 'tinylib-sdk' type GameView = { board: string[]; turn: 'x' | 'o'; winner: 'x' | 'o' | 'draw' | null; you: 'x' | 'o' | null } declare module 'tinylib-sdk' { interface Register { data: { best: number; [key: `entry/${string}`]: string } rooms: { game: { view: GameView; event: { sound: string } } } } } ``` ```ts app.ts const best = tinylib.data.get('best') // number | undefined tinylib.rooms.follow<'game'>(id, ({ view }) => { if (view) drawBoard(view.board) // view is a GameView }) ``` ### `DataKey` ```ts type DataKey = keyof DataShape & string type DataShape = Register extends { data: infer D } ? D : Record ``` A key of `tinylib.data`: one that `data` in [`Register`](https://tinylib.app/docs/api/types.md#register) declares, or any string when it declares none. ### `DataValue` ```ts type DataValue = K extends keyof DataShape ? DataShape[K] : unknown type DataShape = Register extends { data: infer D } ? D : Record ``` The type of the value under key `K`, as `data` in [`Register`](https://tinylib.app/docs/api/types.md#register) declares it, or `unknown` when it doesn't. ### `RoomKind` ```ts type RoomKind = keyof RoomShape & string type RoomShape = Register extends { rooms: infer R } ? R : Record ``` A kind: one that `rooms` in [`Register`](https://tinylib.app/docs/api/types.md#register) declares, or any string when it declares none. ### `RoomView` ```ts type RoomView = RoomTypes extends { view: infer V } ? V : unknown type RoomTypes = K extends keyof RoomShape ? RoomShape[K] : unknown type RoomShape = Register extends { rooms: infer R } ? R : Record ``` The type of a member's view in rooms of kind `K`, as `rooms` in [`Register`](https://tinylib.app/docs/api/types.md#register) declares it, or `unknown` when it doesn't. ### `RoomEvent` ```ts type RoomEvent = RoomTypes extends { event: infer E } ? E : unknown type RoomTypes = K extends keyof RoomShape ? RoomShape[K] : unknown type RoomShape = Register extends { rooms: infer R } ? R : Record ``` The type of the events the rules emit in rooms of kind `K`, as `rooms` in [`Register`](https://tinylib.app/docs/api/types.md#register) declares it, or `unknown` when it doesn't. ### `Member` ```ts interface Member ``` A user as your app sees them: in `ctx.members` in the rules, and in [`tinylib.friends.list`](https://tinylib.app/docs/api/friends.md#list) and an invitation's `from` in your client. Properties: - `id: string`: The same in every room of your app. - `name: string`: Names change and aren't unique, so keep `id` in state, not `name`. - `guest: boolean`: True for a [guest](https://tinylib.app/docs/guide/rooms.md#guests): a user without a Tinylib account, known only on the device they use. A guest has data and acts in rooms like anyone, and gets no notifications or invitations. ### `Audience` ```ts type Audience = 'me' | 'friends' ``` Who can read a value: `'me'`, the user alone, or `'friends'`, the user and their friends who have the app. ## tinylib-sdk/kits > What apps share: turn games, solo games, daily puzzles, and games with friends. Source: https://tinylib.app/docs/api/kits.md Your page imports these from `tinylib-sdk/kits`, and `publish` bundles them into the version. Your rules import the ones that need no `tinylib`: the rule kits below, `addDays`, `seeded`, `shuffled`, `clock`, and `today` given a date. [Games](https://tinylib.app/docs/guide/games.md) shows them in use. ```js import { daily, followGames, today } from 'tinylib-sdk/kits' ``` ### Dates and randomness #### `today()` ```ts today(date?: Date): string ``` Returns the date on Tinylib's clock in the device's time zone, as `'YYYY-MM-DD'`. ```js const day = today() // '2026-10-20' ``` Parameters: - `date?: Date`: Defaults to now on Tinylib's clock. #### `addDays()` ```ts addDays(day: string, days: number): string ``` Returns the date `days` after `day`, both as `'YYYY-MM-DD'`. A negative `days` counts back. ```js const yesterday = addDays(today(), -1) ``` Throws `TypeError`: - When `day` isn't a date as `'YYYY-MM-DD'`. #### `seeded()` ```ts seeded(seed: string): () => number ``` Returns a function like `Math.random` whose sequence is fixed by `seed`. The same seed gives the same sequence on every device, so seeding with the date deals everyone the same puzzle. ```js const random = seeded(`hard/${today()}`) const target = 100 + Math.floor(random() * 900) ``` #### `shuffled()` ```ts shuffled(items: readonly T[], random?: () => number): T[] ``` Returns a shuffled copy of `items`. ```js const tiles = shuffled(letters, seeded(`tiles/${today()}`)) ``` Parameters: - `items: readonly T[]`: The items to shuffle. - `random?: () => number`: A function like `Math.random`, such as one from [`seeded`](https://tinylib.app/docs/api/kits.md#seeded). Defaults to `Math.random`. ### Daily results #### `daily()` ```ts daily(name: string): Daily ``` Returns the daily puzzle `name`, which saves one result per date in `tinylib.data` with `audience: 'friends'`, so [`leaderboard`](https://tinylib.app/docs/api/kits.md#leaderboard) can rank it among friends' results. Saving needs `"friends"` in [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses). ```js const hard = daily('hard') hard.save({ seconds: 312 }, day) const days = hard.streak() ``` Parameters: - `name: string`: The puzzle's name: letters, digits, `-` and `_`, starting with a letter or digit. Throws `TypeError`: - When `name` isn't a valid name. #### `Daily` ```ts interface Daily ``` The user's results of one daily puzzle, from [`daily`](https://tinylib.app/docs/api/kits.md#daily). Methods: - [`key(day?)`](https://tinylib.app/docs/api/kits.md#puzzle-key): Returns the `tinylib.data` key the result for `day` is saved under, `'day//YYYY-MM-DD'`. - [`get(day?)`](https://tinylib.app/docs/api/kits.md#puzzle-get): Returns the user's result for `day`, or `undefined` when they have none. - [`save(result, day?)`](https://tinylib.app/docs/api/kits.md#puzzle-save): Saves `result` for `day` with `audience: 'friends'` and returns true. - [`played()`](https://tinylib.app/docs/api/kits.md#puzzle-played): Returns the dates the user has a result for, as `'YYYY-MM-DD'`, oldest first. - [`streak(from?)`](https://tinylib.app/docs/api/kits.md#puzzle-streak): Returns how many days in a row the user has a result for, ending at `from`. ##### `puzzle.key()` ```ts puzzle.key(day?: string): string ``` Returns the `tinylib.data` key the result for `day` is saved under, `'day//YYYY-MM-DD'`. Pass it to [`leaderboard`](https://tinylib.app/docs/api/kits.md#leaderboard) or [`friendsWith`](https://tinylib.app/docs/api/kits.md#friendswith) for friends' results that day. ```js const rows = await leaderboard(hard.key(day), (a, b) => a.seconds - b.seconds) ``` Parameters: - `day?: string`: `'YYYY-MM-DD'`. Defaults to today. ##### `puzzle.get()` ```ts puzzle.get(day?: string): T | undefined ``` Returns the user's result for `day`, or `undefined` when they have none. ```js const result = hard.get(day) // { seconds: 312 } ``` Parameters: - `day?: string`: `'YYYY-MM-DD'`. Defaults to today. ##### `puzzle.save()` ```ts puzzle.save(result: unknown, day?: string): boolean ``` Saves `result` for `day` with `audience: 'friends'` and returns true. When this device already has a result for `day`, it keeps that one and returns false. ```js if (!hard.save({ seconds: 312 }, day)) showResult(hard.get(day)) ``` Parameters: - `result: unknown`: Any JSON value, such as `{ seconds: 312, mistakes: 1 }`. - `day?: string`: `'YYYY-MM-DD'`. Defaults to today. Throws `TypeError`: - When `result` can't be saved as JSON. Throws `TinylibError` codes: - `limit`: When it would take the user's data from your client past 1 MB. ##### `puzzle.played()` ```ts puzzle.played(): string[] ``` Returns the dates the user has a result for, as `'YYYY-MM-DD'`, oldest first. ```js const played = new Set(hard.played()) ``` ##### `puzzle.streak()` ```ts puzzle.streak(from?: string): number ``` Returns how many days in a row the user has a result for, ending at `from`. When `from` has no result yet, it counts from the day before, so a streak lasts until a whole day is missed. ```js streakLabel.textContent = `${hard.streak()}-day streak` ``` Parameters: - `from?: string`: `'YYYY-MM-DD'`. Defaults to today. #### `leaderboard()` ```ts leaderboard( key: string, compare: (a: T, b: T) => number, options?: { mine?: T; valid?: (value: unknown) => boolean }, ): Promise[]> ``` Resolves with the user's and each friend's value under `key`, best first by `compare`. On a tie, the user's row comes first. ```js const rows = await leaderboard(hard.key(day), (a, b) => a.seconds - b.seconds, { valid: (value) => typeof value?.seconds === 'number', }) for (const row of rows) renderRow(row.you ? 'You' : row.name, clock(row.value.seconds)) ``` Parameters: - `key: string`: The key each value is saved under, such as `daily('hard').key()`. - `compare: (a: T, b: T) => number`: As `Array.prototype.sort` takes: negative when `a` is better than `b`. - `options?: { mine?: T; valid?: (value: unknown) => boolean }` - `options.mine?: T`: The user's value, for example to rank a result before it's saved. Defaults to `tinylib.data.get(key)`. When it's `undefined`, the user has no row. - `options.valid?: (value: unknown) => boolean`: Returns false for a friend's value to leave out, such as one saved by an older version of your app. Rejects with `TinylibError` codes: - `offline`: When the device is offline. - `refused`: When your app doesn't list `"friends"` in `uses`, or the user is a guest. #### `Standing` ```ts interface Standing ``` One row of [`leaderboard`](https://tinylib.app/docs/api/kits.md#leaderboard): the user's or a friend's. Properties: - `id: string`: The user's or the friend's id. - `name: string`: The user's or the friend's name. - `value: T`: The value under the key, or `options.mine` in the user's row when `leaderboard` was given it. - `you: boolean`: True for the user's own row. #### `friendsWith()` ```ts friendsWith(key: string): Promise ``` Resolves with each friend's value under `key`, with the friend's name, in the order of `tinylib.friends.list()`. Friends without a value are left out. ```js const friends = await friendsWith(hard.key(day)) // [{ id, name: 'Ben', value: { seconds: 280 }, changedAt }] ``` Parameters: - `key: string`: A key friends save values under with `audience: 'friends'`, such as `daily('hard').key()`. Rejects with `TinylibError` codes: - `offline`: When the device is offline. - `refused`: When your app doesn't list `"friends"` in [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses), or the user is a guest. #### `FriendResult` ```ts interface FriendResult ``` A friend's value under one key, from [`friendsWith`](https://tinylib.app/docs/api/kits.md#friendswith). Properties: - `id: string`: The friend's id. - `name: string`: The friend's name. - `value: unknown`: The value the friend saved. - `changedAt: number`: When the value last changed, in milliseconds on Tinylib's clock. Compare it with `tinylib.now()`. ### Solo games #### `solo()` ```ts solo(name: string, better?: (a: any, b: any) => boolean): SoloGames ``` Returns the solo games of the puzzle `name`. ```js const games = solo('sudoku') games.keep(TODAY, { grid }) const { record } = games.record('hard', seconds) ``` Parameters: - `name: string`: The puzzle, as `daily` takes it. - `better?: (a: any, b: any) => boolean`: Says whether score `a` beats `b`. Lower wins by default. #### `SoloGames` ```ts interface SoloGames ``` The solo games of a puzzle, from [`solo`](https://tinylib.app/docs/api/kits.md#solo). Properties: - `results: Daily`: The day's results, from [`daily(name)`](https://tinylib.app/docs/api/kits.md#daily). Methods: - [`key(mode)`](https://tinylib.app/docs/api/kits.md#sologames-key): Returns the data key of the game kept in `mode`, for watching it change. - [`saved(mode)`](https://tinylib.app/docs/api/kits.md#sologames-saved): Returns the game kept in `mode`, or null. - [`keep(mode, game)`](https://tinylib.app/docs/api/kits.md#sologames-keep): Keeps `game` in `mode`. - [`clear(mode)`](https://tinylib.app/docs/api/kits.md#sologames-clear): Removes the game kept in `mode`. - [`best(mode)`](https://tinylib.app/docs/api/kits.md#sologames-best): Returns the best score in `mode`, or null. - [`record(mode, score, options?)`](https://tinylib.app/docs/api/kits.md#sologames-record): Keeps `score` as the best in `mode` when it beats it, with `options` as `tinylib.data.set` takes them, and returns the best from before it and whether this is a record. - [`week(day?)`](https://tinylib.app/docs/api/kits.md#sologames-week): Returns the seven days ending `day`, each with its date, its result, and whether it's that day. ##### `SoloGames.key()` ```ts SoloGames.key(mode: string): string ``` Returns the data key of the game kept in `mode`, for watching it change. ##### `SoloGames.saved()` ```ts SoloGames.saved(mode: string): any ``` Returns the game kept in `mode`, or null. Today's counts only on its day. ##### `SoloGames.keep()` ```ts SoloGames.keep(mode: string, game: object): void ``` Keeps `game` in `mode`. Today's stays on `game.day`, or on today when it has none. ##### `SoloGames.clear()` ```ts SoloGames.clear(mode: string): void ``` Removes the game kept in `mode`. ##### `SoloGames.best()` ```ts SoloGames.best(mode: string): any ``` Returns the best score in `mode`, or null. ##### `SoloGames.record()` ```ts SoloGames.record(mode: string, score: any, options?: { audience?: Audience }): { best: any; record: boolean } ``` Keeps `score` as the best in `mode` when it beats it, with `options` as `tinylib.data.set` takes them, and returns the best from before it and whether this is a record. ##### `SoloGames.week()` ```ts SoloGames.week(day?: string): { day: string; result: unknown; today: boolean }[] ``` Returns the seven days ending `day`, each with its date, its result, and whether it's that day. #### `TODAY` ```ts const TODAY = 'today' ``` The mode of today's puzzle. #### `followFriendsToday()` ```ts followFriendsToday(key: string, compare: (a: T, b: T) => number, options: { mine?: T; valid?: (value: unknown) => boolean } | undefined, fn: (friends: FriendsToday) => void): () => void ``` Follows friends' results for the day `key` names, best first by `compare`, with `options` as `leaderboard` takes them. Calls `fn` now and each time the connection changes, and returns a function that stops it. #### `FriendsToday` ```ts interface FriendsToday ``` What [`followFriendsToday`](https://tinylib.app/docs/api/kits.md#followfriendstoday) calls its function with. Properties: - `rows: Standing[]` - `loading: boolean` - `online: boolean` - `guest: boolean` - `error: TinylibError | null` #### `friendsToday()` ```ts friendsToday(key: string, options: FriendsTodayOptions): Placed ``` Makes a list of the results under `key`, such as `games.results.key(day)` or `best/classic`, the player's own among them and marked, best first by `compare`, filled in when online. A friend's name opens Block and Report. The list is a `section` of `className`, with `li`s that have `.you` for the player's own, and `.place`, `.name`, `.result`, and `.tools` inside them, a `p.note`, and a `button.send` with `.loud` or `.quiet`. Your page styles them. #### `FriendsTodayOptions` ```ts interface FriendsTodayOptions ``` What [`friendsToday`](https://tinylib.app/docs/api/kits.md#friendstoday) takes. Properties: - `className?: string` - `title?: string`: The list's heading. Default: `'Friends today'`. - `alone?: string`: What the list says when no friend has played. - `mine?: T`: The user's value, as `leaderboard` takes it. Methods: - `compare: (a: T, b: T) => number`: As `leaderboard` takes: negative when `a` is better than `b`. - `show: (result: T) => Node | string`: Turns one result into text or an element. - `valid?: (value: unknown) => boolean`: Returns false for a friend's value to leave out, as `leaderboard` takes it. #### `reminderSwitch()` ```ts reminderSwitch(options: { title: string; text?: string; className?: string; address?: string }): Placed ``` Makes "Remind me each day" with a time, shown once the current reminder is read, and hidden offline. Parameters: - `options: { title: string; text?: string; className?: string; address?: string }` - `options.title: string`: The reminder's title. - `options.text?: string`: The reminder's text. - `options.className?: string`: The switch's class. - `options.address?: string`: The address the reminder opens. Defaults to `'today'`. #### `Placed` ```ts interface Placed ``` An element a kit made, and a function that stops it. Properties: - `node: HTMLElement` Methods: - `stop: () => void` ### Clocks #### `stopwatch()` ```ts stopwatch(onTick?: (seconds: number) => void, every?: number): Stopwatch ``` Returns a stopwatch that counts seconds from `start` until `pause`. It doesn't count while the page is hidden, so time the user spends in another app is left out. ```js const watch = stopwatch((seconds) => { timer.textContent = clock(seconds) }) watch.start() // Once the puzzle is solved: watch.pause() hard.save({ seconds: Math.round(watch.seconds) }, day) ``` Parameters: - `onTick?: (seconds: number) => void`: Called with the seconds counted, every `every` milliseconds while the stopwatch counts. - `every?: number`: Milliseconds between calls to `onTick`. Defaults to 1000. #### `Stopwatch` ```ts interface Stopwatch ``` A stopwatch, from [`stopwatch`](https://tinylib.app/docs/api/kits.md#stopwatch). Properties: - `seconds: number`: The seconds counted, with their fraction. Set it to carry on from a time saved earlier. - `readonly running: boolean`: True after `start` and before `pause`, except while the page is hidden. Methods: - [`start()`](https://tinylib.app/docs/api/kits.md#watch-start): Counts on from `seconds`. - [`pause()`](https://tinylib.app/docs/api/kits.md#watch-pause): Stops counting and keeps `seconds`. ##### `watch.start()` ```ts watch.start(): void ``` Counts on from `seconds`. ##### `watch.pause()` ```ts watch.pause(): void ``` Stops counting and keeps `seconds`. #### `countdown()` ```ts countdown(fn: (left: number) => void): () => void ``` Calls `fn` now and every second with the milliseconds left until midnight. When the date changes, it calls `fn` with 0 and stops. Returns a function that stops the calls. ```js const stopCountdown = countdown((left) => { if (left) nextPuzzle.textContent = `New puzzle in ${clock(Math.ceil(left / 1000))}` else showToday() }) ``` #### `untilTomorrow()` ```ts untilTomorrow(now?: Date): number ``` Returns the milliseconds from `now` until the next midnight in the device's time zone, when [`today`](https://tinylib.app/docs/api/kits.md#today) changes. ```js const hours = Math.floor(untilTomorrow() / 3_600_000) ``` Parameters: - `now?: Date`: Defaults to now on Tinylib's clock. #### `clock()` ```ts clock(seconds: number): string ``` Formats `seconds` as `'M:SS'`, or `'H:MM:SS'` from an hour on. Fractions of a second are dropped. ```js clock(83) // '1:23' clock(3725) // '1:02:05' ``` ### Daily reminder #### `followDailyReminder()` ```ts followDailyReminder( name: string, options: Omit, fn: (reminder: DailyReminder) => void, ): { set(time: string | null): Promise; stop(): void } ``` Keeps a toggle on your page in step with the daily reminder `name`. Calls `fn` with the reminder now, and again after each `set`, when the device goes online or offline, and when the user changes your app's switches in Tinylib. `set(time)` makes the reminder go off every day at `time`, `'HH:MM'` on a 24-hour clock, or cancels it when `time` is null. If that fails, `set` rejects with the error and calls `fn` with the reminder unchanged, so your toggle flips back. `stop` stops the calls. Needs `"reminders"` in [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses). ```js const reminder = followDailyReminder('sudoku', { title: 'Sudoku', text: "Today's puzzle is ready." }, ({ available, time, muted }) => { reminderRow.hidden = !available reminderToggle.checked = time !== null reminderTime.value = time ?? '09:00' remindersOff.hidden = !muted }) reminderToggle.onchange = async () => { try { await reminder.set(reminderToggle.checked ? reminderTime.value : null) } catch (error) { toast(error.message) } } ``` Parameters: - `name: string`: The reminder's name, as `tinylib.reminders.set` takes it. - `options: Omit`: What the reminder says and the address it opens, as `tinylib.reminders.set` takes them. - `fn: (reminder: DailyReminder) => void`: Called with the reminder, as [`DailyReminder`](https://tinylib.app/docs/api/kits.md#dailyreminder). #### `DailyReminder` ```ts interface DailyReminder ``` The user's daily reminder, from [`followDailyReminder`](https://tinylib.app/docs/api/kits.md#followdailyreminder). Properties: - `available: boolean`: False until the reminder has been read, and again while the device is offline or when reading fails. Hide your toggle while it's false. - `time: string | null`: The time it goes off each day, as `'HH:MM'`, or null when it's off. - `muted: boolean`: True when it's on but the user has turned off your app's **Reminders** switch in Tinylib, so it doesn't go off. ### Turn games, in rules #### `defineGame()` ```ts defineGame(spec: GameSpec): Kind ``` Compiles a game to a kind, which a rules file exports like any other. The rules say what each player may do, and that one list gates the move and tells the page what's legal. Who sits where, whose turn it is, the clock, a player who leaves or goes quiet, and the end of the game are the kit's. Wins count as `wins`, which goes in `rulesKeys` in tinylib.json. In a move, `ctx.random()` is the only randomness and `ctx.now` the only clock. ```js export const game = defineGame({ seats: { min: 2, max: 2 }, turn: { limit: null }, prepare(state) { state.table.board = Array(9).fill(null) }, moves: { mark: { options: ({ table }) => table.board.flatMap((cell, at) => (cell === null ? [at] : [])), run: ({ table }, seat, at) => void (table.board[at] = seat.color), }, }, over(state, { by }) { return wonBy(state.table.board, by?.color) ? { winners: [by.id] } : null }, }) ``` #### `GameSpec` ```ts interface GameSpec ``` A game, as [`defineGame`](https://tinylib.app/docs/api/kits.md#definegame) takes it. Properties: - `address?: string`: The kind's address, which notifications and links open. Default: `'game/:id'`. - `idleMs?: number`: How long a room lasts with nobody in it. Defaults to 30 days. - `migrate?: Kind['migrate']` - `presence?: Kind['presence']` - `guests?: boolean` - `seats: { min: number; max: number; wait?: boolean }`: The table starts when `max` is reached, or when the host starts it from `min`. `wait: true` leaves a full table for the host to start. - `turn: GameTurn | false`: The game's turns, or false for a game with none. - `show?: { table?(state: S): object; seat?(seat: GameSeat, state: S): object }`: What everyone sees besides `table`, such as how many cards are in a hidden hand. Runs once the game has started, and may also shape what `table` shows. - `moves: Record>`: What the players can do, by name. Methods: - [`prepare?(state, options, ctx)`](https://tinylib.app/docs/api/kits.md#gamespec-prepare): Runs when the room is made, for what the page draws while waiting: settings, an empty board. - [`setup?(state, ctx)`](https://tinylib.app/docs/api/kits.md#gamespec-setup): Deals when the game starts, and may return what happened, for `said` and to tell the first player. - [`first?(state, ctx)`](https://tinylib.app/docs/api/kits.md#gamespec-first): Returns the seat index to start at, or 0 if you leave it out. - [`left?(state, seat, ctx)`](https://tinylib.app/docs/api/kits.md#gamespec-left): Tidies up after a player who left or ran out of time, such as cards back in the deck. - [`over?(state, turn)`](https://tinylib.app/docs/api/kits.md#gamespec-over): Returns `{ winners, why }` when the game is over, or null. - [`score?(state, outcome)`](https://tinylib.app/docs/api/kits.md#gamespec-score): Returns what to add to any outcome, however the game ended, such as scores. - [`text?(outcome, id, name)`](https://tinylib.app/docs/api/kits.md#gamespec-text): Words the result for `id`, which is null for nobody in particular, or returns undefined for the default. ##### `GameSpec.prepare()` ```ts GameSpec.prepare?(state: S, options: any, ctx: Context): void ``` Runs when the room is made, for what the page draws while waiting: settings, an empty board. ##### `GameSpec.setup()` ```ts GameSpec.setup?(state: S, ctx: Context): string | void ``` Deals when the game starts, and may return what happened, for `said` and to tell the first player. ##### `GameSpec.first()` ```ts GameSpec.first?(state: S, ctx: Context): number ``` Returns the seat index to start at, or 0 if you leave it out. ##### `GameSpec.left()` ```ts GameSpec.left?(state: S, seat: GameSeat, ctx: Context): void ``` Tidies up after a player who left or ran out of time, such as cards back in the deck. Setting `state.said` here replaces the kit's "left the game". ##### `GameSpec.over()` ```ts GameSpec.over?(state: S, turn: { canMove: boolean; by: GameSeat | null }): ({ winners: string[]; why?: string | null } & Record) | null | undefined ``` Returns `{ winners, why }` when the game is over, or null. `by` is the seat that just moved, or null, and `canMove` says whether the player whose turn it is has a move. Anything else returned stays on the outcome. ##### `GameSpec.score()` ```ts GameSpec.score?(state: S, outcome: Outcome): object | undefined ``` Returns what to add to any outcome, however the game ended, such as scores. ##### `GameSpec.text()` ```ts GameSpec.text?(outcome: Outcome, id: string | null, name: (id: string) => string): string | undefined ``` Words the result for `id`, which is null for nobody in particular, or returns undefined for the default. `name` names a player. #### `GameTurn` ```ts interface GameTurn ``` A game's turns. Properties: - `limit: number | null | ((state: S) => number | null)`: The milliseconds a turn may take, null for no clock, or a function of the state for a limit the room chose. - `timeout?: string[]`: The moves tried in order when the time runs out, each with `options`, and the first option is played. With none, the turn passes. - `strikes?: number | null`: How many times in a row a player can run out of time before they're out. Defaults to 3, and to 1 for a game with no `timeout`; null is never. - `warn?: number | null`: How many milliseconds before the limit to remind the player. Methods: - [`passed?(state)`](https://tinylib.app/docs/api/kits.md#gameturn-passed): Runs whenever the turn moves on, for what a turn keeps count of. ##### `GameTurn.passed()` ```ts GameTurn.passed?(state: S): void ``` Runs whenever the turn moves on, for what a turn keeps count of. #### `Move` ```ts interface Move ``` One thing a player can do. Give it `options` or `check`. Properties: - `who?: 'turn' | 'any' | 'host'`: Who may make it. Defaults to `'turn'` in a game with turns, and to `'any'` in one without. - `when?: 'playing' | 'waiting'`: When it can be made. Default: `'playing'`. - `keeps?: boolean`: Leaves the turn, the clock, `moved`, and `said` alone, for a turn shared as it's built, and doesn't ask `over`. - `refuse?: string`: The sentence for data that isn't on the list. Methods: - [`options?(state, seat)`](https://tinylib.app/docs/api/kits.md#move-options): Returns the list of what the player may send. - [`check?(state, seat, data)`](https://tinylib.app/docs/api/kits.md#move-check): Refuses what the player sent by returning a sentence, for a move too open to list. - [`run(state, seat, data, ctx)`](https://tinylib.app/docs/api/kits.md#move-run): Makes the move, and may return what happened, as words after the player's name. ##### `Move.options()` ```ts Move.options?(state: S, seat: GameSeat): unknown[] ``` Returns the list of what the player may send. A move that takes nothing lists `[null]`. ##### `Move.check()` ```ts Move.check?(state: S, seat: GameSeat, data: any): string | null | undefined ``` Refuses what the player sent by returning a sentence, for a move too open to list. ##### `Move.run()` ```ts Move.run(state: S, seat: GameSeat, data: any, ctx: MoveContext): string | void ``` Makes the move, and may return what happened, as words after the player's name. A move ends the turn unless it calls `ctx.stay()`. #### `MoveContext` ```ts interface MoveContext extends Omit ``` The `ctx` of a move: a room's [`ctx`](https://tinylib.app/docs/rules/ctx.md) with no `user` or `refuse`, since a timeout plays moves too. Extends `Omit`. Properties: - `timedOut: boolean`: Whether a timeout played this move. Methods: - [`stay()`](https://tinylib.app/docs/api/kits.md#movecontext-stay): Keeps the turn with this player. - [`turnTo(seat)`](https://tinylib.app/docs/api/kits.md#movecontext-turnto): Says who goes next, or the next still in after them. ##### `MoveContext.stay()` ```ts MoveContext.stay(): void ``` Keeps the turn with this player. ##### `MoveContext.turnTo()` ```ts MoveContext.turnTo(seat: GameSeat): void ``` Says who goes next, or the next still in after them. #### `GameState` ```ts interface GameState ``` The state of a room `defineGame` made. Keep a game's own state in `table`, `hidden`, and each `seat.private`. Properties: - `phase: 'waiting' | 'playing' | 'over'` - `seats: GameSeat[]` - `turn: number`: The index in `seats` of the player whose turn it is. - `clock: number`: Counts the turns. - `deadline: number | null` - `said: Said | null` - `table: Table`: What everyone sees. - `hidden: Hidden`: What nobody sees. - `outcome: Outcome | null` #### `GameSeat` ```ts interface GameSeat extends Seat ``` A player at the table, as the rules see them. Extends [`Seat`](https://tinylib.app/docs/api/kits.md#seat), so it also has `id`, `color`, `here`, and `out`. Properties: - `name: string` - `guest: boolean` - `moved: boolean`: Whether they've made a move of their own. - `out: boolean`: Whether they're out of the game. A move that knocks a player out sets it. - `late: number`: How many times in a row their time ran out. - `private: Private`: What only this player sees. #### `Said` ```ts interface Said ``` The last thing that happened: `by` and `name` say who did it, when someone did. Properties: - `by: string | null` - `name: string | null` - `text: string` #### `Outcome` ```ts interface Outcome ``` How a game ended, with whatever `over` and `score` added. Properties: - `winners: string[]` - `why: string | null`: `over`'s `why`, or the kit's own: `'left'`, `'time'`, `'unplayed'`, or `'stuck'`. - `leaver: string | null`: The id of the player who left or ran out of time. - `timedOut?: boolean`: With `'unplayed'`, whether the player ran out of time rather than left. #### `GameView` ```ts interface GameView ``` What a member sees of a game. Properties: - `phase: GameState['phase']` - `you: string | null` - `host: string | null` - `turn: string | null` - `deadline: number | null`: When this turn runs out, or null. - `clock: number`: Counts the turns. - `said: Said | null` - `line: string | null`: `said` as a sentence for this member: "You drew a card." - `players: SeatView[]`: Who's still in. - `seats: SeatView[]`: Everyone who sat down. - `limits: { min: number; max: number }`: The seats the table holds. - `table: any`: `state.table` plus `show.table`. Before the game starts, only what `prepare` set. - `private: any`: The member's own `seat.private`, or null. - `legal: Record`: What the member may do now, by move: the list of what they may send, or true for a move with `check`. The host's `start` is there once the table can start. - `outcome: Outcome | null` - `status: string`: The member's own sentence. #### `SeatView` ```ts interface SeatView ``` A seat as a member's view shows it, plus `show.seat`. Properties: - `id: string` - `at: number`: The seat's index. - `name: string` - `guest: boolean` - `color: number` - `moved: boolean` - `out: boolean` #### `FinalView` ```ts interface FinalView extends GameView ``` What a game keeps when it ends: the view of nobody in particular, under `final` in the room's data, with each player's own sentence. Extends [`GameView`](https://tinylib.app/docs/api/kits.md#gameview), so it also has `phase`, `you`, `host`, `turn`, `deadline`, `clock`, `said`, `line`, `players`, `seats`, `limits`, `table`, `private`, `legal`, `outcome`, and `status`. Properties: - `results: Record` #### `current()` ```ts current(state: GameState): GameSeat ``` Returns the seat whose turn it is. In `over` and `left`, the turn may already have moved on: use `by` to see who moved. #### `ifGone()` ```ts ifGone(seats: Pick[], seat: Pick, min: number): 'on' | 'rest' | 'void' ``` Returns what `seat` going would do to a game in play, from `seats` of a state or a view and the table's `min`: `'on'` when the rest play on, `'rest'` when the game goes to the rest, and `'void'` when it ends with no winner. ### Seats, in rules #### `seats()` ```ts seats(ctx: Context, more?: Record): Seats ``` Returns the seats of a room just made, with its maker in the first, in color 0, for a kind's `create` to spread into its state. `more` goes on their seat. #### `Seats` ```ts interface Seats ``` What a room's state holds of its seats. Properties: - `seats: S[]` #### `Seat` ```ts interface Seat ``` A player's seat. `more` of [`seats`](https://tinylib.app/docs/api/kits.md#seats) and [`sit`](https://tinylib.app/docs/api/kits.md#sit) goes on it too. Properties: - `id: string` - `color: number`: Their color: the lowest number free when they sat down. - `here?: boolean`: Whether they have the room open, once [`present`](https://tinylib.app/docs/api/kits.md#present) keeps it. - `out?: boolean`: Whether they're out of the game but keep the seat. #### `seatOf()` ```ts seatOf(state: Seats, id: string | null): S | undefined ``` Returns the seat of `id`, or undefined. #### `playing()` ```ts playing(state: Seats): S[] ``` Returns the seats still in the game. #### `hostOf()` ```ts hostOf(state: Seats): S | undefined ``` Returns the host's seat: the first still in, which is the room's maker until they go. #### `full()` ```ts full(state: Seats, most: number): boolean ``` Returns whether every seat of `most` is taken. #### `sit()` ```ts sit(state: Seats, ctx: Context, most: number, more?: Record): Seat ``` Seats the member who joined in the first color free of `most`, with `more` on their seat, and returns the seat. #### `stand()` ```ts stand(state: Seats, id: string): void ``` Frees the seat of `id`. #### `present()` ```ts present(state: Seats, ctx: Context, open: boolean): void ``` Keeps whether the member has the room open, for a kind's `presence`. #### `anyoneHere()` ```ts anyoneHere(state: Seats): boolean ``` Returns whether any player has the room open. #### `allHere()` ```ts allHere(state: Seats): boolean ``` Returns whether every player has the room open. #### `remove()` ```ts remove(state: Seats, id: string, ctx: Context): void ``` Removes `id` from the room, when the member acting is the host and `id` is another player. An action can be this. #### `seatsIn()` ```ts seatsIn(state: Seats, members: Pick[]): (Seat & { name: string; guest: boolean; here: boolean })[] ``` Returns the seats as a view shows them: each with its name, whether they're a guest, and whether they're here. ### Games with friends #### `followGames()` ```ts followGames(kind: RoomKind | null, fn: (games: Games) => void): () => void ``` Calls `fn` with the user's games and game invitations now, and again whenever they change or the device goes online or offline. Returns a function that stops the calls. While it or [`followGame`](https://tinylib.app/docs/api/kits.md#followgame) runs, Tinylib doesn't show those invitations on its own [sheet](https://tinylib.app/docs/start/how-it-works.md#tinylib-s-sheets), so draw `invitations` yourself, as in [Show invitations](https://tinylib.app/docs/guide/games.md#show-invitations). ```js const stop = followGames('fourInARow', ({ current, past, invitations, loading, online, error }) => { renderGames(current, past) renderInvitations(invitations) noGames.hidden = loading || !online || current.length > 0 gamesMessage.textContent = error?.message ?? (online ? '' : "Your games come back when you're online.") }) ``` Parameters: - `kind: RoomKind | null`: Only rooms and invitations of this kind, or null for every kind. - `fn: (games: Games) => void`: Called with the games, as [`Games`](https://tinylib.app/docs/api/kits.md#games). #### `Games` ```ts interface Games ``` The user's games, from [`followGames`](https://tinylib.app/docs/api/kits.md#followgames). Properties: - `current: RoomListItem[]`: Games still going, the ones waiting on the user first. - `past: RoomListItem[]`: Games that ended, most recently ended first, then games the user left, most recently left first. - `invitations: Invitation[]`: The user's invitations to games of this kind, newest first. - `loading: boolean`: True until the games have been read, and again after the device goes online or offline until they're read again. While it's true, empty lists don't mean the user has no games. - `online: boolean`: Whether the device is online. Offline, `current` is empty. - `error: TinylibError | null`: Why the list couldn't be read, or null. #### `followGame()` ```ts followGame(id: string, fn: (game: Game) => void): () => void ``` Opens the room `id` and calls `fn` with the game on each new view, each change to its invitations, and each time the device goes online or offline. Until the game has been read, `view`, `room`, `error`, and `past` are null. Returns a function that closes the room and stops the calls. While it runs, Tinylib doesn't show the invitations in `invitations`, `rematch`, and `duplicate` on its own sheet. Draw them yourself without covering the game, with a way to block and report the sender. ```js const stop = followGame(id, (game) => { renderInvitations(game.invitations) if (game.past?.left) renderLeft(game.past) else if (game.past && !game.view) renderFinished(id, game.past) else if (game.error) showMessage(game.error.message) else if (game.view) renderBoard(game.view, game.room) else showSpinner() }) ``` Parameters: - `id: string`: The room's id. - `fn: (game: Game) => void`: Called with the game, as [`Game`](https://tinylib.app/docs/api/kits.md#game). #### `Game` ```ts interface Game ``` One game, from [`followGame`](https://tinylib.app/docs/api/kits.md#followgame). Properties: - `view: RoomView | null`: The user's latest view, or null before the first. - `room: OpenRoom | null`: The open room, or null when it couldn't be opened. Its `ended` says whether the room has ended, and `left` whether the user left it. - `error: TinylibError | null`: Why the game couldn't be opened, or null. - `past: RoomListItem | null`: The game's entry in `tinylib.rooms.list()` once it has ended or the user has left it, or null. A game that's over can't be opened again, so when `followGame` starts on one, `view` stays null and `error` has the code `ended`, or `not_member` if the user left. - `past.left` is true when the user left or was removed. Its `status` and `members` are as they were then. - Otherwise the game ended, and its `members` leave out anyone who left before then. - `past.closed` is true when the game ended without the rules calling `ctx.end`: nobody played it for the kind's `idleMs`, or its last player left. No handler ran then, so the rules saved nothing at the end, and its `status` is the last one they gave. - `rematch: Invitation | null`: Another member's invitation to a rematch of this game, or null. When several members asked for one, everyone gets the same one. - `duplicate: Invitation | null`: The invitation to the rematch everyone keeps, or null. It's set when the user started this game with [`playAgain`](https://tinylib.app/docs/api/kits.md#playagain) at the same moment another member started a rematch of the same game. `playAgain(game)` then joins the kept game and leaves this one. - `invitations: Invitation[]`: The user's other invitations to games of this game's kind, or of a kind [`followGames`](https://tinylib.app/docs/api/kits.md#followgames) has listed, newest first. - `online: boolean`: Whether the device is online. #### `playAgain()` ```ts playAgain(game: Pick & Partial>, options?: unknown): Promise<{ id: string }> ``` Starts a rematch of a game, open or ended, or one the user left, and resolves with `{ id }`, the room to open. - With `game.duplicate`, it joins that member's room and leaves this one. - With `game.rematch`, it accepts it. - For a game the user left, or one that ended more than a day ago, it creates a room of the same kind and invites the members `game.past` lists, except guests. - Otherwise it accepts another member's invitation to a rematch of this game, if there is one. If not, it creates a room of the same kind with [`tinylib.rooms.create`](https://tinylib.app/docs/api/rooms.md#create) and `rematchOf`, which invites the game's other members who have an account. To play again with guests, don't end the room: reset it with an action. ```js again.onclick = async () => { try { const { id } = await playAgain(game) tinylib.places.go(`game/${id}`) } catch (error) { toast(error.message) } } ``` Parameters: - `game: Pick & Partial>`: The game, from [`followGame`](https://tinylib.app/docs/api/kits.md#followgame). - `options?: unknown`: Passed to the kind's `create` when this makes the room. Rejects with `TypeError`: - When `game` has no `room`, `past`, `rematch`, or `duplicate`, or `options` can't be saved as JSON. Rejects with `TinylibError` codes: - `not_found`: When the invitation it accepts no longer lets the user in. - `refused`: When the user is a guest, or the kind's `create` refuses. A rematch with `rematchOf` is also refused when the user has blocked a member or been blocked by one. - `limit`: When the user is already in 50 rooms of your app, has created rooms faster than 60 per hour in your app, or the room it joins already has 64 members. - `failed`: When the kind's `create` throws. - `offline`: When the device is offline. ### Game pages #### `gamePages()` ```ts gamePages(address: string, options?: GamePagesOptions): GamePages ``` Returns the pages of a game at `address`, as its kind says it. ```js const pages = gamePages('game/:id', { noun: 'table' }) const stop = pages.follow(id, ({ view, gone, message, again }) => draw(view ?? gone ?? message, again)) ``` #### `GamePagesOptions` ```ts interface GamePagesOptions ``` How [`gamePages`](https://tinylib.app/docs/api/kits.md#gamepages) words a game. Properties: - `invitation?: string`: What an invitation says after the sender's name, unless it's for a rematch. Default: `'wants to play'`. - `noun?: string`: What the game calls a game, such as "table", for its buttons. Default: `'game'`. - `loading?: string`: What shows until the game is read. Default: `'Loading…'`. Methods: - `rematch?: (name: string) => string`: Words the button for a rematch someone sent. - `settings?: (view: GameView) => unknown`: A new game's settings, from a view of this one. #### `GamePages` ```ts interface GamePages ``` The pages of a game, from [`gamePages`](https://tinylib.app/docs/api/kits.md#gamepages). Methods: - [`open(id, replace?)`](https://tinylib.app/docs/api/kits.md#gamepages-open): Goes to the game `id`, replacing this address with `replace`. - [`invited(invitation)`](https://tinylib.app/docs/api/kits.md#gamepages-invited): Returns what `invitation` says, and its two answers, which open the game or dismiss it. - [`follow(id, fn)`](https://tinylib.app/docs/api/kits.md#gamepages-follow): Follows the game `id` as `followGame` does, and calls `fn` with what to draw of it. ##### `GamePages.open()` ```ts GamePages.open(id: string, replace?: boolean): void ``` Goes to the game `id`, replacing this address with `replace`. ##### `GamePages.invited()` ```ts GamePages.invited(invitation: Invitation): { text: string; accept: () => Promise; dismiss: () => Promise } ``` Returns what `invitation` says, and its two answers, which open the game or dismiss it. ##### `GamePages.follow()` ```ts GamePages.follow(id: string, fn: (game: GameScreen) => void): () => void ``` Follows the game `id` as `followGame` does, and calls `fn` with what to draw of it. Returns a function that stops it. #### `GameScreen` ```ts interface GameScreen extends Omit ``` What to draw of a game, from [`GamePages`](https://tinylib.app/docs/api/kits.md#gamepages-type)'s `follow`. Exactly one of `view`, `gone`, and `message` says what the screen is. Extends `Omit`. Properties: - `view: GameView | null`: The board: the live view, or the one the rules kept once the game is over. - `ended: boolean`: Whether the game is over, or has no open room to play in, as while it loads. - `people: Person[]`: Everyone else seen in this game, kept after they leave so Block and Report stay. - `gone: { text: string; people: Person[] } | null`: For a game with no board: what to say, and who else played. - `message: string | null`: A line to show alone: the game loading, its error, or no connection. - `again: ButtonProps`: The rematch button, hidden until the game ends. It invites whoever has an account, or starts a new game with nobody to invite. - `twin: { text: string; join: ButtonProps }`: For a room waiting while someone else's rematch of the same game waits too: what to say, or nothing, and the button that joins theirs. #### `ButtonProps` ```ts interface ButtonProps ``` A button's properties. Properties: - `hidden: boolean` - `textContent: string` Methods: - `onclick: () => Promise` #### `Person` ```ts interface Person ``` Someone seen in a game. Properties: - `id: string` - `name?: string` - `guest?: boolean` #### `drawStrip()` ```ts drawStrip(strip: HTMLElement, invitations: Invitation[], row: (invitation: Invitation) => Node): void ``` Fills `strip` with `row` of each invitation, only when the invitations change. #### `followWins()` ```ts followWins(fn: (wins: number) => void): () => void ``` Calls `fn` with how many games the user has won, now and whenever it changes. Returns a function that stops it. #### `leaveWarning()` ```ts leaveWarning(view: GameView, options?: { playsOn?: string }): string | null ``` Returns what to ask before the user leaves the game in `view`, or null when leaving costs nothing. `playsOn` is the question for a table that carries on without them. #### `leaveGame()` ```ts leaveGame(id: string): Promise ``` Leaves the game `id` and goes back to the first page. #### `roomRow()` ```ts roomRow(room: RoomListItem, options: { title?: (names: string) => string; empty: string }): { people: Person[]; title: string; status: string } ``` Returns for a row in the games list who else is in `room`, its `title` with their names or else `empty`, and its `status`. ### Playing with no signal #### `localRoom()` ```ts localRoom(kind: Kind, config: { players: { id: string; name: string }[]; options?: unknown; now?: () => number; random?: () => number }): LocalRoom ``` Runs a room of `kind` on this device, for a game against bots with no signal. It's your rules, unchanged, with what Tinylib does around them done here. Nothing it keeps is saved, notifications go nowhere, and everyone in it has it open. ```js const clock = pausingClock() const room = localRoom(game, { players: [{ id: 'you', name: 'You' }, { id: 'bot', name: 'Bot' }], now: clock.now }) const port = room.port('you') port.onView(draw) setInterval(room.tick, 100) ``` Parameters: - `kind: Kind`: The kind your rules export. - `config: { players: { id: string; name: string }[]; options?: unknown; now?: () => number; random?: () => number }` - `config.players: { id: string; name: string }[]`: Who's in it. The first makes it. - `config.options?: unknown`: What `create` gets. - `config.now?: () => number`: The clock. Defaults to Tinylib's. - `config.random?: () => number`: The random numbers `ctx.random` gives. #### `LocalRoom` ```ts interface LocalRoom ``` A room on this device, from [`localRoom`](https://tinylib.app/docs/api/kits.md#localroom). Properties: - `readonly ended: boolean` - `readonly name: string | null`: The name the rules gave the room. - `readonly roomData: Values`: What the rules wrote to the room's data. Methods: - [`port(id)`](https://tinylib.app/docs/api/kits.md#localroom-port): Returns the member `id`'s way into the room. - [`join(player)`](https://tinylib.app/docs/api/kits.md#localroom-join) - [`leave(id)`](https://tinylib.app/docs/api/kits.md#localroom-leave) - [`tick()`](https://tinylib.app/docs/api/kits.md#localroom-tick): Runs what's due. - [`values(id)`](https://tinylib.app/docs/api/kits.md#localroom-values): What the rules wrote for the member `id`. ##### `LocalRoom.port()` ```ts LocalRoom.port(id: string): LocalPort ``` Returns the member `id`'s way into the room. ##### `LocalRoom.join()` ```ts LocalRoom.join(player: { id: string; name: string }): void ``` ##### `LocalRoom.leave()` ```ts LocalRoom.leave(id: string): void ``` ##### `LocalRoom.tick()` ```ts LocalRoom.tick(): void ``` Runs what's due. Nothing moves until it's called, so call it often. ##### `LocalRoom.values()` ```ts LocalRoom.values(id: string): Values ``` What the rules wrote for the member `id`. #### `LocalPort` ```ts interface LocalPort ``` A member's way into a [`LocalRoom`](https://tinylib.app/docs/api/kits.md#localroom-type): what a page gets from `tinylib.rooms`, plus `view` and `onView`. Properties: - `id: string` - `readonly ended: boolean` - `readonly left: boolean` Methods: - [`view()`](https://tinylib.app/docs/api/kits.md#localport-view): Returns what the rules' `view` gives this member. - [`flush()`](https://tinylib.app/docs/api/kits.md#localport-flush): Tells `onView`'s functions, if the view changed, and then `onEvent`'s. - [`onView(fn)`](https://tinylib.app/docs/api/kits.md#localport-onview): Calls `fn` with the view whenever it changes. - [`onEvent(fn)`](https://tinylib.app/docs/api/kits.md#localport-onevent) - [`onRelay(fn)`](https://tinylib.app/docs/api/kits.md#localport-onrelay) - [`relay(data)`](https://tinylib.app/docs/api/kits.md#localport-relay) - [`act(action, data?)`](https://tinylib.app/docs/api/kits.md#localport-act) ##### `LocalPort.view()` ```ts LocalPort.view(): any ``` Returns what the rules' `view` gives this member. ##### `LocalPort.flush()` ```ts LocalPort.flush(): void ``` Tells `onView`'s functions, if the view changed, and then `onEvent`'s. ##### `LocalPort.onView()` ```ts LocalPort.onView(fn: (view: any) => void): () => void ``` Calls `fn` with the view whenever it changes. Returns a function that stops it. ##### `LocalPort.onEvent()` ```ts LocalPort.onEvent(fn: (event: unknown) => void): () => void ``` ##### `LocalPort.onRelay()` ```ts LocalPort.onRelay(fn: (from: string, data: unknown, at: number) => void): () => void ``` ##### `LocalPort.relay()` ```ts LocalPort.relay(data: unknown): void ``` ##### `LocalPort.act()` ```ts LocalPort.act(action: string, data?: unknown): Promise ``` #### `pausingClock()` ```ts pausingClock(): { now: () => number; stop: () => void } ``` Returns a clock for [`localRoom`](https://tinylib.app/docs/api/kits.md#localroom) that stops while the page is hidden, so a game waits for the user. `stop` ends the watching. ### Connection #### `needsConnection()` ```ts needsConnection(elements: { disabled: boolean }[]): () => void ``` Disables each of `elements` without a connection and enables it when one returns. Returns a function that stops it. ```js const stop = needsConnection([hostButton, joinButton]) ``` #### `NO_CONNECTION` ```ts const NO_CONNECTION = 'No connection' ``` What an app says without a connection. #### `connectionLine()` ```ts connectionLine(list: { online: boolean; error: (Error & { code?: string }) | null }): string ``` Returns the line that goes with a list that follows the connection, from the `{ online, error }` it was called with: [`NO_CONNECTION`](https://tinylib.app/docs/api/kits.md#no-connection) without one, its error, or nothing. ### People #### `personRows()` ```ts personRows(person: { id: string; guest?: boolean }, options?: { after?: (done: boolean) => void }): PersonRow[] ``` Returns `person`'s rows: Block, and Report between accounts, not by a guest and not of one. `after` gets what Tinylib's sheet answered, and an error goes to the toast. #### `PersonRow` ```ts interface PersonRow ``` A row of what can be done about a person: its `label`, and `run` to do it. Properties: - `label: string` Methods: - `run: () => Promise` #### `personButtons()` ```ts personButtons(person: { id: string; guest?: boolean }, options?: { className?: string; after?: (done: boolean) => void }): HTMLButtonElement[] ``` Returns `person`'s rows as buttons with `className`. #### `moreButton()` ```ts moreButton(panel: HTMLElement, { key, ...props }?: { key?: string } & Partial): HTMLButtonElement ``` Makes a "…" button that opens and closes `panel`, with the rest of its properties in `props`. With `key`, a room's or a person's id, a panel drawn again comes back as it was left. ### Screens #### `route()` ```ts route(screens: Record, options?: { keep?: boolean }): () => void ``` Shows one screen at a time, chosen by the part of `tinylib.places.address` before the first `/`. When the address changes, it stops the current screen and shows the next. Screens get the address without its query; read the query from `tinylib.places.address`. Returns a function that stops the current screen and stops switching. ```js route({ '': showGames, // the start address game: showGame, // 'game/abc' calls showGame('abc') }, { keep: true }) ``` Parameters: - `screens: Record`: Screens keyed by the part of the address before the first `/`. Each is called with the rest of the address. The screen under `''` shows the start address and any address no other key matches, and is called with the whole address. When a screen throws, nothing shows until the address changes again. - `options?: { keep?: boolean }` - `options.keep?: boolean`: When true, going to the address already showing leaves its screen as it is instead of starting it again. #### `Screen` ```ts type Screen = (rest: string) => (() => void) | void ``` A screen [`route`](https://tinylib.app/docs/api/kits.md#route) shows. It draws itself and can return a function that stops it. #### `$()` ```ts $(id: string): T ``` Returns the element with the id `id`. #### `element()` ```ts element(tag: K, props?: Partial, children?: (Node | string)[]): HTMLElementTagNameMap[K] ``` Makes an element with `props` set on it and `children` inside it. ```js const row = element('li', { className: 'row' }, [element('span', { textContent: name }), ' played']) ``` #### `toast()` ```ts toast(text: string): void ``` Shows `text` for a moment in the element with the id `toast`, which it adds if the page has none. #### `show()` ```ts show(error: Error): void ``` Toasts an error's message, for a `catch`. ### Words #### `listNames()` ```ts listNames(names: string[]): string ``` Writes `names` in a sentence. ```js listNames(['Ben', 'Cy', 'Di']) // 'Ben, Cy, and Di' ``` #### `initial()` ```ts initial(name: string): string ``` Returns a name's first letter for an avatar, or `'?'` for a blank name. #### `plural()` ```ts plural(count: number, word: string): string ``` Writes `count` of `word`, with an s unless there's one. ```js plural(3, 'game') // '3 games' ``` ## Kinds > Everything a kind in rules.js can define, and when each part runs. Source: https://tinylib.app/docs/rules/kinds.md ### `Kind` ```ts interface Kind ``` `rules.js` exports each kind by name, such as `game`, as an object holding the handlers that run every room of that kind. Your client makes a room of it with `tinylib.rooms.create('game')`. Every named export that's an object with a `create` function is a kind. Tinylib ignores other exports, so you can export helpers too. `publish` refuses rules with no kind, or with a kind that breaks a rule on this page. Tinylib runs a room's inputs one at a time, in the order they arrive, each on the state the last one left. Each input runs one handler: | Handler | Runs when | `ctx.user` | | --- | --- | --- | | `create` | A client calls [`tinylib.rooms.create`](https://tinylib.app/docs/api/rooms.md#create). | The creator | | `actions[name]` | A member's client calls [`room.act`](https://tinylib.app/docs/api/rooms.md#room-act). | The member who acted | | `join` | Someone joins by an invitation or the room's link. Never for the creator. | The new member | | `leave` | A member leaves or is removed. | The member who left | | `presence` | A member comes into the room or opens it on their first device, or has had it closed everywhere for 5 seconds. | That member | | `timers[name]` | A timer set with `ctx.setTimer` goes off. | `null` | | `beat` | Every `beatMs` while a member has the room open. | `null` | | `migrate` | Anything happens in the room for the first time under a new version, before that input's handler. | `null` | `create` and actions answer a client's call: `ctx.refuse` rejects the call with `refused`, and a throw rejects it with `failed`. The other inputs have already happened, so they can't refuse. If one of them throws, its changes are dropped but the input stands: someone who joined is still a member. Every error shows on [your developer page](https://tinylib.app/developer). After each input that neither refuses nor throws, Tinylib calls `view`, `status`, and `canInvite` for every member with the new state. They get no `ctx`, and Tinylib may call them again at any time, so compute their result from their arguments alone; changes they make to `state` aren't kept. If one throws for a member, that member keeps what they had, and the input still stands. No handler runs when a room ends, whether by `ctx.end`, by its last member leaving, or by going unused for its `idleMs`. Record results as they happen. The rules run under these conditions: - Handlers are synchronous. `dev` and `publish` refuse an `async` handler, and a handler that returns a promise fails. - The rules can't reach the network: `fetch`, `XMLHttpRequest`, and `WebSocket` aren't there. - `rules.js` can import your own files and npm packages. From `tinylib-sdk` it can import types and `tinylib-sdk/kits`: `defineGame`, the seats, the words, `addDays`, `seeded`, `shuffled`, `clock`, and `today` given a date run in rules, and the rest call the page's `tinylib`. Any other import of `tinylib-sdk` stops `npx tinylib-sdk dev` and `publish` with an error. - Keep everything a room needs in `state`. A module-level variable may last between inputs and be shared between rooms, or it may be lost at any time. - Use `ctx.now` instead of `Date.now()`, and `ctx.random()` instead of `Math.random()`. - `state` is saved as JSON after each input, so a `Map` or an `undefined` doesn't survive. An input that would leave the state and its timers past 1 MB changes nothing: `create` and actions reject with `limit`, and any other handler's changes are dropped and show on your developer page. - A handler that runs past 50 milliseconds of CPU time is stopped, and its input fails as if it had thrown. - `console.log` shows in the browser's console in `npx tinylib-sdk dev`. In a published app it goes nowhere. To type a kind, write `import type { Kind } from 'tinylib-sdk/rules'` in `rules.ts`, or annotate it with `@type {import('tinylib-sdk/rules').Kind}` in `rules.js`. ```js export const counter = { create() { return { count: 0 } }, actions: { add(state) { state.count += 1 }, }, view(state) { return { count: state.count } }, } ``` Type parameters: - ``: The type of the room's state. Default: `any`. - ``: The type of the options `create` receives. Default: `any`. - ``: The type `view` returns. Default: `unknown`. Properties: - `address?: string`: The path your app opens at when someone comes to a room of this kind from outside the app, like a notification or an invitation. Write it without a leading `/`. `:id` becomes the room's id, as in `'game/:id'`. Default: the start screen. - `idleMs?: number`: How long a room of this kind lasts unused, in milliseconds, counted from the last time a member had it open, acted, joined, or left. Timers don't count. It must be from 1 minute to 365 days. Members keep a room that ends this way in their past rooms, with its last status and its room data. Default: 7 days. ```js idleMs: 365 * 24 * 60 * 60 * 1000 // a list kept for a year without anyone opening it ``` ```js idleMs: 60 * 60 * 1000 // a game played in one sitting, closed after an hour unused ``` - `offline?: true`: Set to `true` to let members act in a room of this kind without a connection, as in a shared list. Every member gets the whole state, so the kind has no `view` and keeps no secrets. The state must fit in 256 KB. ```js export const list = { offline: true, 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)) state.items.push({ id: item.id, text: item.text, done: false }) }, }, } ``` - `guests?: boolean`: Set to `false` to keep people without a Tinylib account out of this kind: they can't create a room of it, or join one by its link or code. By default anyone can. See [guests](https://tinylib.app/docs/guide/rooms.md#guests). Default: true. - `beatMs?: number`: How often `beat` runs, in milliseconds, for a game that moves in real time. It must be from 50 milliseconds to 1 second, and a kind with `beat` needs it. For anything slower, set a timer. ```js beatMs: 100 // ten times a second ``` Functions: - [`create(options, ctx)`](https://tinylib.app/docs/rules/kinds.md#kind-create): Makes a new room's first state. - `actions?: Record>`: The kind's actions, by name. `room.act('move', 4)` runs `actions.move(state, 4, ctx)`. A call naming an action the kind doesn't have fails. - `timers?: Record>`: The kind's timers, by name. `ctx.setTimer('turn', at, data)` runs `timers.turn(state, data, ctx)` at `at`, with `ctx.user` null. ```js timers: { turn(state) { state.turn = state.turn === 'x' ? 'o' : 'x' }, }, ``` - [`beat?(state, relays, ctx)`](https://tinylib.app/docs/rules/kinds.md#kind-beat): Runs every `beatMs` while at least one member has the room open, with every relay members sent since the last beat, in the order the room took them. - [`join?(state, via, ctx)`](https://tinylib.app/docs/rules/kinds.md#kind-join): Runs after someone joins, once they're a member. - [`leave?(state, why, ctx)`](https://tinylib.app/docs/rules/kinds.md#kind-leave): Runs after a member leaves or is removed. - [`presence?(state, present, ctx)`](https://tinylib.app/docs/rules/kinds.md#kind-presence): Runs when a member arrives or goes. - [`migrate?(state, fromVersion, ctx)`](https://tinylib.app/docs/rules/kinds.md#kind-migrate): Reshapes a room's state for a new [version](https://tinylib.app/docs/guide/publishing.md), so your other handlers see only the new shape. - [`view?(state, member, members)`](https://tinylib.app/docs/rules/kinds.md#kind-view): Returns what `member` sees of the state, which their client gets through [`room.onView`](https://tinylib.app/docs/api/rooms.md#room-onview). - [`status?(state, member, members)`](https://tinylib.app/docs/rules/kinds.md#kind-status): Returns the line Tinylib shows for this room in `member`'s list of rooms, or null for none. - [`canInvite?(state, member, members)`](https://tinylib.app/docs/rules/kinds.md#kind-caninvite): Returns whether `member` may invite now, with an invitation or the room's link. #### `Kind.create()` ```ts Kind.create(options: Options, ctx: Context): State ``` Makes a new room's first state. `join` doesn't run for the creator. A refusal, a throw, or a first state past the room's [limits](https://tinylib.app/docs/reference/limits.md#rooms) rejects the client's call, and no room is made. ```js create(options, ctx) { return { board: Array(9).fill(''), players: { x: ctx.user.id, o: null }, turn: 'x', winner: null } }, ``` Parameters: - `options: Options`: What the client passed as `options` to [`tinylib.rooms.create`](https://tinylib.app/docs/api/rooms.md#create), or `{}`. Any user can send anything, so check it before you use it. - `ctx: Context`: The input's [`Context`](https://tinylib.app/docs/rules/ctx.md). `ctx.user` is the creator, and `ctx.members` lists only them. Returns `State`: The room's first state. Returning `undefined` fails the call. #### `Kind.beat()` ```ts Kind.beat?(state: State, relays: Relay[], ctx: Context): void | undefined ``` Runs every `beatMs` while at least one member has the room open, with every relay members sent since the last beat, in the order the room took them. The first beat comes `beatMs` after a member opens the room, and the beats stop once nobody has it open. Positions travel once, as relays: other members draw them at once, and the rules check and keep them here. Send what happens once, such as a hit or a pickup, with [`room.act`](https://tinylib.app/docs/api/rooms.md#room-act). ```js beatMs: 100, beat(state, relays, ctx) { for (const { from, data: [x, y], at } of relays) { const player = state.players[from] if (player && at > player.at && reachable(player, x, y, at)) Object.assign(player, { x, y, at }) } if (ctx.now >= state.endsAt) state.over = true }, ``` Parameters: - `state: State`: The room's state, to change in place. - `relays: Relay[]`: The relays since the last beat, in the order the room took them. Each sender's arrive in the order they sent them. - `ctx: Context`: The input's [`Context`](https://tinylib.app/docs/rules/ctx.md). `ctx.user` is null, and `ctx.now` is when this beat ran. #### `Kind.join()` ```ts Kind.join?(state: State, via: JoinVia, ctx: Context): void | undefined ``` Runs after someone joins, once they're a member. It can't turn them away; to stop people joining, return false from `canInvite`. A full room turns joins away before `join` runs; the most members a room holds is 64. ```js join(state, via, ctx) { if (state.players.o === null) state.players.o = ctx.user.id }, ``` Parameters: - `state: State`: The room's state, to change in place. - `via: JoinVia`: How they joined. - `ctx: Context`: The input's [`Context`](https://tinylib.app/docs/rules/ctx.md). `ctx.user` is the new member, and `ctx.members` includes them. #### `Kind.leave()` ```ts Kind.leave?(state: State, why: LeaveReason, ctx: Context): void | undefined ``` Runs after a member leaves or is removed. When the last member leaves, the room ends after `leave`. ```js leave(state, why, ctx) { if (state.winner !== null) return state.winner = state.players.x === ctx.user.id ? 'o' : 'x' ctx.data.room.set('final', { board: state.board, winner: state.winner }) ctx.end() }, ``` Parameters: - `state: State`: The room's state, to change in place. - `why: LeaveReason`: Why they left. - `ctx: Context`: The input's [`Context`](https://tinylib.app/docs/rules/ctx.md). `ctx.user` is the member who left, and `ctx.members` no longer includes them, but `ctx.data` can still read and write their data. #### `Kind.presence()` ```ts Kind.presence?(state: State, present: boolean, ctx: Context): void | undefined ``` Runs when a member arrives or goes. Opening the room on another device, or opening it again within 5 seconds of closing it, runs nothing. Parameters: - `state: State`: The room's state, to change in place. - `present: boolean`: True when the member comes into the room or opens it on their first device. False once they've had it closed on every device for 5 seconds. - `ctx: Context`: The input's [`Context`](https://tinylib.app/docs/rules/ctx.md). `ctx.user` is the member who came or went. #### `Kind.migrate()` ```ts Kind.migrate?(state: State, fromVersion: number, ctx: Context): void | undefined ``` Reshapes a room's state for a new [version](https://tinylib.app/docs/guide/publishing.md), so your other handlers see only the new shape. It runs the next time anything happens in the room, before that input's handler, so in a room nobody has used for a while, `fromVersion` can be several versions old. Before a version goes live, the [room check](https://tinylib.app/docs/guide/publishing.md#change-the-rules-of-a-live-app) runs `migrate` on a copy of every room that hasn't ended. ```js migrate(state, fromVersion) { if (fromVersion < 4) state.moves = [] // version 4 added moves }, ``` Parameters: - `state: State`: The state as the earlier version's rules left it, to change in place. - `fromVersion: number`: The number of the version whose rules last handled an input in this room. - `ctx: Context`: The input's [`Context`](https://tinylib.app/docs/rules/ctx.md). `ctx.user` is null, and `ctx.version` is `fromVersion`. #### `Kind.view()` ```ts Kind.view?(state: State, member: Member, members: Member[]): View ``` Returns what `member` sees of the state, which their client gets through [`room.onView`](https://tinylib.app/docs/api/rooms.md#room-onview). Every kind but an `offline` one needs it. What `view` leaves out never reaches that member's device, so `view` is how a game keeps secrets. Tinylib sends a member their view when it changes. A view must be JSON, and one past 64 KB isn't sent. ```js 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 } }, ``` Parameters: - `state: State`: The room's state. Changes to it aren't kept. - `member: Member`: The member whose view this is. - `members: Member[]`: Every current member, in the order they joined. Unlike `ctx.members`, they don't have `joinedAt` or `present`, so a view can't show who has the room open. #### `Kind.status()` ```ts Kind.status?(state: State, member: Member, members: Member[]): Status | null ``` Returns the line Tinylib shows for this room in `member`'s list of rooms, or null for none. A status never sends a notification; use `ctx.notify` for that. A status whose text is past 200 characters isn't set. ```js status(state, member) { if (state.winner) return { text: 'Game over', waiting: false } const waiting = state.players[state.turn] === member.id return { text: waiting ? 'Your move' : 'Their move', waiting } }, ``` Parameters: - `state: State`: The room's state. Changes to it aren't kept. - `member: Member`: The member whose status this is. - `members: Member[]`: Every current member, as `view` gets them. #### `Kind.canInvite()` ```ts Kind.canInvite?(state: State, member: Member, members: Member[]): boolean ``` Returns whether `member` may invite now, with an invitation or the room's link. Without `canInvite`, every member may. Returning anything but `true` or `false` counts as a throw. An invitation works while the member who sent it may invite, and the room's link and code work while any member may. Each join is its own input, so a seat that `canInvite` closes is closed before the next join. ```js canInvite(state) { return state.players.o === null }, ``` Parameters: - `state: State`: The room's state. Changes to it aren't kept. - `member: Member`: The member it's asked about. - `members: Member[]`: Every current member, as `view` gets them. ### `Handler` ```ts type Handler = (state: State, data: Data, ctx: Context) => void | undefined ``` An action's or a timer's handler. It changes `state` in place; returning a new state fails the input. To start over, assign fresh fields to `state`, as `again` does below. For an action, `data` is what the member's client passed to [`room.act`](https://tinylib.app/docs/api/rooms.md#room-act), as JSON, or `undefined`. Any member can send any value, so check it and refuse what you don't accept. For a timer, `data` is what `ctx.setTimer` was given. ```js export const game = { // ... actions: { move(state, square, ctx) { if (!Number.isInteger(square) || square < 0 || square > 8 || state.board[square] !== '') { ctx.refuse('Pick an empty square.') } // ... }, again(state) { Object.assign(state, { board: Array(9).fill(''), turn: 'x', winner: null }) }, }, } ``` ### `Relay` ```ts interface Relay ``` A relay as `beat` receives it: who sent it, its numbers as other members got them, and when it was sent. Properties: - `from: string`: The sender's id, as in `ctx.members`. - `data: number[]`: The numbers, rounded as [`room.relay`](https://tinylib.app/docs/api/rooms.md#room-relay) rounds them. - `at: number`: When the sender's client says it sent it, on Tinylib's clock, at most 100 milliseconds later than `ctx.now`. Like `data`, it comes from the sender's device. ### `Status` ```ts interface Status ``` What a [`Kind`](https://tinylib.app/docs/rules/kinds.md#kind)'s `status` returns for one member. Properties: - `text: string`: The line Tinylib shows, such as `'Your move'`. - `waiting: boolean`: True when the room is waiting for this member to act. Tinylib lists the rooms waiting on the user. ### `JoinVia` ```ts type JoinVia = { invite: Member } | 'link' ``` How someone joined, as `join` receives it: `{ invite }` with the member whose invitation they accepted, or `'link'` when they used the room's link or code. A guest gets no invitations, so always joins by `'link'`. ### `LeaveReason` ```ts type LeaveReason = 'left' | 'removed' ``` Why a member left a room, as `leave` receives it. | Value | When | | ------ | ------ | | `'left'` | The member left, or Tinylib took them out because they deleted their data in your app. | | `'removed'` | The rules removed the member with `ctx.remove`. | ## ctx > What the rules can read and do through ctx while they handle an input. Source: https://tinylib.app/docs/rules/ctx.md Every handler but `view`, `status`, and `canInvite` gets `ctx` as its last argument. A handler's effects, like events and notifications, happen together after it returns, and only if it neither refuses nor throws. Otherwise none of them happens and `state` is unchanged. If Tinylib can't carry out one effect, it skips that effect alone and shows the error on [your developer page](https://tinylib.app/developer) and in `npx tinylib-sdk dev`. An input that would leave the room's state or data past its limit is the exception: none of its effects happen. ### `ctx.roomId` ```ts ctx.roomId: string ``` The room's id. ### `ctx.kind` ```ts ctx.kind: string ``` The kind this room runs: the name of its export in `rules.js`. ### `ctx.now` ```ts ctx.now: number ``` When Tinylib took this input, in milliseconds since the Unix epoch. It's the same throughout the input. In a timer's handler, it's when the timer went off, at or after its `at`. Use it instead of `Date.now()`, which doesn't follow `npx tinylib-sdk dev` when it moves the clock forward to test timers. ### `ctx.version` ```ts ctx.version: number ``` The number of the [version](https://tinylib.app/docs/guide/publishing.md) whose rules last handled an input in this room without refusing or throwing. In `migrate`, it's `fromVersion`. ### `ctx.members` ```ts ctx.members: RoomMember[] ``` The room's current members, guests included, in the order they joined. In `create` it's the creator alone. In `join` it includes the new member, and in `leave` it no longer includes the member who left. ### `ctx.user` ```ts ctx.user: Member | null ``` The user the input is about: the creator in `create`, the member who acted in an action, the new member in `join`, the member who left in `leave`, and the member who came or went in `presence`. Null in a timer's handler, in `beat`, and in `migrate`. ### `ctx.random()` ```ts ctx.random(): number ``` Returns a random number like `Math.random()`, drawn on Tinylib's servers. Members can't predict it, so use it for dice and shuffles. ```js roll(state, data, ctx) { state.die = 1 + Math.floor(ctx.random() * 6) }, ``` ### `ctx.data` ```ts ctx.data: ((userId: string) => MemberValues) & { room: RoomData } ``` `ctx.data(userId)` is a member's data, as [`MemberValues`](https://tinylib.app/docs/rules/ctx.md#membervalues), where `userId` is a current member's id, or the leaving member's in `leave`. `ctx.data.room` is the room's data, as [`RoomData`](https://tinylib.app/docs/rules/ctx.md#roomdata). ```js move(state, square, ctx) { // ... if (state.winner) { if (state.winner !== 'draw') ctx.data(ctx.user.id).add('wins', 1, { audience: 'friends' }) ctx.data.room.set('final', { board: state.board, winner: state.winner }) } }, ``` Throws `TypeError`: - When `userId` isn't a string. ### `ctx.refuse()` ```ts ctx.refuse(sentence: string): never ``` Stops the handler, drops everything it did, and rejects the client's call with `refused`, with `sentence` as the error's `message`. A `try`/`catch` around it doesn't stop the refusal. ```js move(state, square, ctx) { if (state.players[state.turn] !== ctx.user.id) ctx.refuse("It's not your turn.") // ... }, ``` Parameters: - `sentence: string`: A sentence for the user. Past 300 characters, it's cut. Throws `TypeError`: - When it's called outside `create` and actions, the inputs a client waits on. The input then fails, even when a `try`/`catch` catches the error. ### `ctx.emit()` ```ts ctx.emit(to: Recipients, event: unknown): void ``` Sends `event` to the members in `to` who have the room open, through [`room.onEvent`](https://tinylib.app/docs/api/rooms.md#room-onevent), after this input's views. Nobody else ever gets it, so use events for one-off moments like a sound, and put what members must see in `view`. An id that isn't a member's, or an event past 64 KB, is skipped. ```js move(state, square, ctx) { // ... ctx.emit('everyone', { sound: 'place', square }) }, ``` Throws `TypeError`: - When `event` can't be saved as JSON. - When `to` isn't a [`Recipients`](https://tinylib.app/docs/rules/ctx.md#recipients) value, or is `'user'` where `ctx.user` is null. ### `ctx.notify()` ```ts ctx.notify(to: string, notice: Notice): void ``` Sends a notification to member `to`. Tapping it opens the room at the kind's `address`. It needs `"notifications"` in [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses); without it, nothing is sent. It reaches the member only when they don't have the room open anywhere, haven't turned off your app's **Rooms** switch, and haven't muted the room. Guests never get one. A member has one notification from the room at a time, and a newer one replaces it. Opening or leaving the room clears it there and on the member's Android phones; another browser clears it once the room opens in it. An id that isn't a member's is skipped, and so is a notification whose title or text is too long; nothing is cut to fit. Past 60 per hour to one member, or 300 per hour from the room, Tinylib drops the room's notifications without an error. ```js move(state, square, ctx) { // ... const next = state.players[state.turn] if (next) ctx.notify(next, { title: 'Your turn', text: `${ctx.user.name} played.` }) }, ``` Parameters: - `to: string`: The id of a current member. - `notice: Notice`: What the notification says. - `notice.title: string`: The notification's first line, up to 80 characters. - `notice.text?: string`: The line under the title, up to 300 characters. Throws `TypeError`: - When `to` isn't a string, `notice` isn't an object, or its `title` or `text` isn't a string. ### `ctx.setName()` ```ts ctx.setName(text: string): void ``` Sets the room's name, which members see in their list of rooms. Tinylib trims it, and skips a name that's empty, past 60 characters, or caught by its filter. ```js create(options, ctx) { if (typeof options.title === 'string') ctx.setName(options.title) // ... }, ``` Throws `TypeError`: - When `text` isn't a string. ### `ctx.setTimer()` ```ts ctx.setTimer(name: string, at: number, data?: unknown): void ``` Runs `timers[name]` with `data` at time `at`. Each name holds one timer: setting it again moves it and replaces its `data`. For several deadlines, like one clock per player, keep them in `state` and set the timer for the soonest. An `at` in the past goes off as soon as it can. A timer set in a timer's handler or in `beat` waits at least 1 second. The rules can't read a timer back, so keep any deadline your client shows in `state`. Timers count toward the size of the room's state. They don't keep a room going: one unused for its kind's `idleMs` ends, and ending a room cancels its timers. A timer with no handler by its name fails when it goes off, and one whose handler throws doesn't run again. ```js move(state, square, ctx) { // ... ctx.setTimer('turn', ctx.now + 60_000) }, ``` Parameters: - `name: string`: The name of its handler in `timers`. - `at: number`: Milliseconds since the Unix epoch, as `ctx.now` gives them. - `data?: unknown`: Any JSON value, passed to the timer's handler. Throws `TypeError`: - When `name` isn't a string, `at` isn't a finite number, or `data` can't be saved as JSON. ### `ctx.cancelTimer()` ```ts ctx.cancelTimer(name: string): void ``` Cancels the timer `name`, if it's set. Throws `TypeError`: - When `name` isn't a string. ### `ctx.remove()` ```ts ctx.remove(id: string): void ``` Removes member `id` after this input, then runs `leave` for them with `why` as `'removed'`. They can join again like anyone else. An id that isn't a member's is skipped. Removing the last member ends the room. Throws `TypeError`: - When `id` isn't a string. ### `ctx.end()` ```ts ctx.end(): void ``` Ends the room after this input. This input's views, events, and notifications still go out. Then its timers are canceled, its link stops working, and no handler runs again, `leave` included. Members keep the room in their past rooms, with its last status and its room data. To play again, reset `state` instead. ```js resign(state, data, ctx) { state.winner = state.players.x === ctx.user.id ? 'o' : 'x' ctx.data.room.set('final', { board: state.board, winner: state.winner }) ctx.end() }, ``` ### `Recipients` ```ts type Recipients = 'user' | 'everyone' | string | string[] ``` Who `ctx.emit` reaches: a member's id, a list of ids, `'everyone'` for every current member, or `'user'` for `ctx.user`. ### `MemberValues` ```ts interface MemberValues ``` What the rules keep for a member, under the keys `rulesKeys` declares in [tinylib.json](https://tinylib.app/docs/reference/tinylib-json.md). Only the rules write these keys, and any room of your app can, so a member's wins can add up across rooms. A write is skipped when: - `rulesKeys` doesn't declare its key, or the key is longer than 128 bytes. - It's for someone who isn't a member, other than the leaving member in `leave`. - It would take the user's data your rules write past 64 KB. Methods: - [`get(key)`](https://tinylib.app/docs/rules/ctx.md#membervalues-get): Returns a copy of the value under `key`, including writes made earlier in this input, or `undefined`. - [`keys(prefix?)`](https://tinylib.app/docs/rules/ctx.md#membervalues-keys): Returns the declared keys that have a value and start with `prefix`, sorted. - [`set(key, value, options?)`](https://tinylib.app/docs/rules/ctx.md#membervalues-set): Sets `key` to `value`. - [`remove(key)`](https://tinylib.app/docs/rules/ctx.md#membervalues-remove): Removes `key`. - [`add(key, by, options?)`](https://tinylib.app/docs/rules/ctx.md#membervalues-add): Adds `by` to the number under `key`, starting from 0 when it holds no number. - [`max(key, value, options?)`](https://tinylib.app/docs/rules/ctx.md#membervalues-max): Sets `key` to `value` if `value` is larger or `key` holds no number, even when other rooms write the key at the same time. - [`min(key, value, options?)`](https://tinylib.app/docs/rules/ctx.md#membervalues-min): Sets `key` to `value` if `value` is smaller or `key` holds no number, even when other rooms write the key at the same time. #### `MemberValues.get()` ```ts MemberValues.get(key: string): unknown ``` Returns a copy of the value under `key`, including writes made earlier in this input, or `undefined`. Throws `TypeError`: - When `key` isn't a string or isn't declared in `rulesKeys`. - When the id passed to `ctx.data` isn't a current member's, or the leaving member's in `leave`. #### `MemberValues.keys()` ```ts MemberValues.keys(prefix?: string): string[] ``` Returns the declared keys that have a value and start with `prefix`, sorted. Without `prefix`, returns every one. Throws `TypeError`: - When the id passed to `ctx.data` isn't a current member's, or the leaving member's in `leave`. #### `MemberValues.set()` ```ts MemberValues.set(key: string, value: unknown, options?: { audience?: Audience }): void ``` Sets `key` to `value`. With `audience: 'friends'`, the member's friends who have the app can read it. Without `audience`, the key keeps its audience, or `'me'` for a new key. Throws `TypeError`: - When `key` isn't a string, `value` is `undefined` or can't be saved as JSON, or `audience` isn't `'me'` or `'friends'`. #### `MemberValues.remove()` ```ts MemberValues.remove(key: string): void ``` Removes `key`. Throws `TypeError`: - When `key` isn't a string. #### `MemberValues.add()` ```ts MemberValues.add(key: string, by: number, options?: { audience?: Audience }): void ``` Adds `by` to the number under `key`, starting from 0 when it holds no number. When rooms add to the same key at the same time, every add counts. `audience` works as in `set`. ```js ctx.data(ctx.user.id).add('wins', 1, { audience: 'friends' }) ``` Throws `TypeError`: - When `key` isn't a string, `by` isn't a finite number, or `audience` isn't `'me'` or `'friends'`. #### `MemberValues.max()` ```ts MemberValues.max(key: string, value: number, options?: { audience?: Audience }): void ``` Sets `key` to `value` if `value` is larger or `key` holds no number, even when other rooms write the key at the same time. `audience` works as in `set`. ```js ctx.data(ctx.user.id).max('best', state.score, { audience: 'friends' }) ``` Throws `TypeError`: - When `key` isn't a string, `value` isn't a finite number, or `audience` isn't `'me'` or `'friends'`. #### `MemberValues.min()` ```ts MemberValues.min(key: string, value: number, options?: { audience?: Audience }): void ``` Sets `key` to `value` if `value` is smaller or `key` holds no number, even when other rooms write the key at the same time. `audience` works as in `set`. Throws `TypeError`: - When `key` isn't a string, `value` isn't a finite number, or `audience` isn't `'me'` or `'friends'`. ### `RoomData` ```ts interface RoomData ``` What a room keeps for its members to read while it runs and after it ends, like the final board. Only the room's rules write it. Every member can read all of it with [`tinylib.rooms.data`](https://tinylib.app/docs/api/rooms.md#data), so keep secrets in `state`, behind `view`. Methods: - [`get(key)`](https://tinylib.app/docs/rules/ctx.md#roomdata-get): Returns a copy of the value under `key`, including writes made earlier in this input, or `undefined`. - [`keys(prefix?)`](https://tinylib.app/docs/rules/ctx.md#roomdata-keys): Returns the keys that start with `prefix`, sorted. - [`set(key, value)`](https://tinylib.app/docs/rules/ctx.md#roomdata-set): Sets `key` to `value`. - [`remove(key)`](https://tinylib.app/docs/rules/ctx.md#roomdata-remove): Removes `key`. #### `RoomData.get()` ```ts RoomData.get(key: string): unknown ``` Returns a copy of the value under `key`, including writes made earlier in this input, or `undefined`. Throws `TypeError`: - When `key` isn't a string. #### `RoomData.keys()` ```ts RoomData.keys(prefix?: string): string[] ``` Returns the keys that start with `prefix`, sorted. Without `prefix`, returns every key. #### `RoomData.set()` ```ts RoomData.set(key: string, value: unknown): void ``` Sets `key` to `value`. When the input's writes would leave the room's data past 256 KB, the whole input changes nothing, as the [full-room rule](https://tinylib.app/docs/reference/limits.md#rooms) says. Throws `TypeError`: - When `key` isn't a string, or `value` is `undefined` or can't be saved as JSON. #### `RoomData.remove()` ```ts RoomData.remove(key: string): void ``` Removes `key`. Throws `TypeError`: - When `key` isn't a string. ### `RoomMember` ```ts interface RoomMember extends Member ``` A [`Member`](https://tinylib.app/docs/api/types.md#member) as `ctx.members` lists them. Extends [`Member`](https://tinylib.app/docs/api/types.md#member), so it also has `id`, `name`, and `guest`. Properties: - `joinedAt: number`: When the member joined, in milliseconds on Tinylib's clock. - `present: boolean`: True while the member has the room open on a device, and as they come in, before their page connects. After they close it everywhere, it stays true for 5 seconds, until `presence` runs with `present` false. ## tinylib.json > The file that names your app and lists the features it uses. Source: https://tinylib.app/docs/reference/tinylib-json.md `tinylib.json` sits at the top of the folder you publish and never ships with your app. `npx tinylib-sdk dev` and `publish` report each field that's wrong or unknown. ```json tinylib.json { "$schema": "https://cdn.jsdelivr.net/npm/tinylib-sdk@0.44.0/tinylib.schema.json", "slug": "chess", "name": "Chess", "description": "Live chess with a friend.", "icon": "icon.png", "orientation": "portrait", "fullscreen": true, "rulesKeys": ["rating", "games/"], "uses": ["reminders", "friends", "notifications"] } ``` ### `$schema` ```ts $schema?: string ``` The file's JSON schema. `init` writes it, and `dev` keeps it on the version of tinylib-sdk your app installs. ### `slug` ```ts slug: string ``` Required. Your app's address, such as `chess` in `tinylib.app/chess`: lowercase letters and digits, with single hyphens between them. Your app's first version claims it. At most 40 characters. Warning: Changing the slug doesn't rename your app. Publishing again makes a new app at the new slug. ### `name` ```ts name: string ``` Required. The name Tinylib shows under your app's icon and on its notifications. 1 to 20 characters. ### `description` ```ts description: string ``` Required. One sentence about your app, shown on its card and its page. 1 to 120 characters. ### `icon` ```ts icon: string ``` Required. The path of your app's icon in its folder: a square PNG, at least 512 pixels on a side. `init` adds a placeholder. At most 256 KB. ### `orientation` ```ts orientation?: "portrait" | "landscape" ``` Locks the app's orientation. On the web, it works only with `fullscreen`. Default: unset, so the app turns with the phone. ### `fullscreen` ```ts fullscreen?: boolean ``` Hides the phone's status and navigation bars. On the web, the app goes full screen from a tap, and again at the user's next tap if they leave, except in Safari on iPhone, which keeps its bars. Default: `false`. ### `rulesKeys` ```ts rulesKeys?: string[] ``` Keys of the user's data that only your rules can write, with `ctx.data`, such as a rating. An entry ending in `/` covers every key under it. A new version can't declare a key your client has already written, and declaring any needs a `rules.js`. At most 100 entries, each 1 to 128 bytes. Default: `[]`. Warning: A new version can't remove a declared key, so a key stays declared for the app's life. ### `uses` ```ts uses?: ("reminders" | "friends" | "notifications")[] ``` The features your app uses: `"reminders"` for `tinylib.reminders`, `"friends"` for `tinylib.friends` or data saved with `audience: 'friends'`, and `"notifications"` for `ctx.notify`. Without its entry, a call to `tinylib.reminders.set` or `tinylib.friends` rejects with `refused`, and `ctx.notify` sends nothing and shows an error on the developer page. Default: `[]`. `npx tinylib-sdk dev` and `publish` add an entry you leave out when they find your code calling the feature, but only in what they build, never in this file. ## Command line > The commands that create, run, and publish an app. Source: https://tinylib.app/docs/reference/cli.md The `tinylib-sdk` package installs a command named `tinylib`. Run it with `npx tinylib-sdk `, or as `tinylib ` in your app's `package.json` scripts. It needs Node.js 22 or newer. ```sh npx tinylib-sdk [flags] ``` Use [the developer page](https://tinylib.app/developer) to unpublish an app, make an earlier version current again, remove a preview's testers, and see your rules' errors. ### `init` ```sh npx tinylib-sdk init [folder] [flags] ``` Makes a new app from a template. In a terminal, it asks for each flag you leave out; otherwise, it uses the defaults. `init` writes `tinylib.json`, `icon.png`, `index.html`, `app.js`, and `style.css`, and the `multiplayer` and `game` templates add `rules.js`. It also writes a `.gitignore` and a `package.json` that pins tinylib-sdk as a dev dependency, with `dev` and `publish` scripts. ```sh npx tinylib-sdk init my-app --template multiplayer ``` Arguments: - `folder?`: An empty or new folder. Default: the current folder. Flags: - `--template `: solo: one person's app: their data, on all their devices. multiplayer: people together in a room, with rules. game: tic-tac-toe for two friends, with invitations and rematches. Default: solo. - `--name `: The app's name. Default: the folder's name. - `--slug `: The app's slug. Default: one made from the name. - `--description `: The app's description. Default: the template's. ### `dev` ```sh npx tinylib-sdk dev [folder] [flags] ``` Runs your app on your computer with simulated users and reloads it when you save. It shows each room's inputs and errors. `dev` builds the folder the way `publish` does. Both add a missing [`uses`](https://tinylib.app/docs/reference/tinylib-json.md#uses) entry to the build for each feature your code uses, including through `tinylib-sdk/kits`. ```sh npx tinylib-sdk dev --port 4500 ``` Arguments: - `folder?`: The app's folder. Default: the current folder. Flags: - `--port `: The port to serve on, or the next free one after it. Default: `4400`. - `--no-open`: Doesn't open the browser. ### `login` ```sh npx tinylib-sdk login [flags] ``` Logs this computer in so it can publish. It prints a link and a code; open the link in any browser, sign in, and type the code, and it finishes once you do. The first time, you also sign up as a developer. It keeps the login in `credentials.json` in your config folder, such as `~/.config/tinylib`. There is no token to pass. A coding agent runs `login --no-open` and gives you the link it prints, which you can open on any device. Flags: - `--no-open`: Prints the login link without opening it in the browser. ### `publish` ```sh npx tinylib-sdk publish [folder] [flags] ``` Builds the folder into a new version and makes it current for everyone who has the app. Once your app is listed, each new version waits for review. In a terminal, it logs this computer in first if needed; otherwise, run npx tinylib-sdk login first. - The app's first version claims the slug. If another app has it, `publish` names a free one, and in a terminal offers to write it to `tinylib.json`. - While a listed app's new version waits for review, everyone keeps the current version, and the waiting one is the app's preview, for you and your testers. - Open rooms move to the new rules at their next input, after [`migrate`](https://tinylib.app/docs/guide/publishing.md#change-the-rules-of-a-live-app). Arguments: - `folder?`: The app's folder. Default: the current folder. Flags: - `--no-open`: Prints the login link without opening it in the browser. ### `preview` ```sh npx tinylib-sdk preview [folder] [flags] ``` Publishes the folder as your app's preview, which only you and your testers can open. Running it again updates the preview and keeps its data, rooms, and testers. In a terminal, it logs this computer in first if needed; otherwise, run npx tinylib-sdk login first. - Publish the app first. - A preview ends 14 days after the last `preview`, and its data and rooms are deleted. - A tester opens the preview by signing in to Tinylib with the address you invited. ```sh npx tinylib-sdk preview --tester ben@example.com ``` Arguments: - `folder?`: The app's folder. Default: the current folder. Flags: - `--tester `: Lets the address open the preview and emails it a link. Repeat the flag for each tester. - `--end`: Ends the preview now and deletes its data and rooms. - `--no-open`: Prints the login link without opening it in the browser. ### Global flags - `--help`, `-h`: Prints the commands, or after a command's name, its flags. - `--version`, `-v`: Prints the version of tinylib-sdk. ## Limits > What Tinylib caps, on the free plan and on Pro. Source: https://tinylib.app/docs/reference/limits.md A value's size is its JSON in UTF-8 bytes. [Pro](https://tinylib.app/developer), Tinylib's paid plan for developers, raises how many apps you can have, and that row shows its Pro value in parentheses. ### Apps and versions `npx tinylib-sdk publish` and `preview` refuse a folder past one of these. An app you unpublish still counts toward your apps until you delete it. | Limit | Value | | --- | --- | | Apps per developer | 3 (25 on Pro) | | New versions | 30 per hour | | Previews | 30 per hour | | Size of a version | 5 MB | | Files in a version | 500 | | A file's path | 255 characters | | Bundled rules, with their source map | 1 MB | | Icon file | 256 KB | | Icon's shortest side, at least | 512 pixels | | Testers per preview | 25 | | How long a preview lasts after the last `preview` | 14 days | ### tinylib.json `npx tinylib-sdk dev` reports a field past its limit, and `publish` refuses it. | Limit | Value | | --- | --- | | `slug` | 40 characters | | `name` | 20 characters | | `description` | 120 characters | | Entries in `rulesKeys` | 100 | | A key in `rulesKeys` | 128 bytes | ### Users Past the data limit, `tinylib.data.set` throws a `TinylibError` with code `limit`. For a key that's too long, it throws a `TypeError`. A write by your rules past their limit is skipped and shows as an error on [the developer page](https://tinylib.app/developer). A call past a limit on calls, rooms, short codes, or invitations rejects with `limit`. A room counts toward the user's rooms until it ends or they leave it. Saves past their rate wait on the user's device and sync when the rate allows them. | Limit | Value | | --- | --- | | A user's data, written by your client | 1 MB | | A user's data, written by your rules | 64 KB | | A key in a user's data | 128 bytes | | Saves a user makes in your app | 300 per 10 seconds | | Calls your page makes to Tinylib for a user | 600 per minute | | Rooms a user is in, in your app | 50 | | Rooms your page has open at once | 10 | | Rooms a user creates in your app | 60 per hour | | Short codes a user tries in your app | 10 per minute | | Invitations a user sends from your app | 60 per hour | | Friends a user can have | 200 | ### Rooms `tinylib.rooms.create` with options too large, and `room.act` with an action too large or too frequent, reject with `limit`. `room.relay` sent too often throws `limit`. A room is full when it has its most members. Joining a full room rejects with `limit` and a sentence such as "This room is full." Any input that would leave a room's state or room data past its limit changes nothing. `tinylib.rooms.create` and `room.act` reject with `limit`, and any other handler's changes are dropped and show on the developer page. Tinylib ends a room when nobody has had it open, acted in it, joined it, or left it for its kind's `idleMs`. | Limit | Value | | --- | --- | | Members | 64 | | State | 1 MB | | State of an offline kind | 256 KB | | Room data | 256 KB | | Options a room is created with | 16 KB | | An action's data | 16 KB | | Actions per second, from each member | 30 per second | | Numbers in one relay | 32 | | Relays per second, from each client | 30 per second | | Relays per second a room takes, from all its members | 1920 per second | | How far a relay's `at` can read ahead of Tinylib's clock | 100 milliseconds | | People without an account joining by the room's link | 128 per hour | | Default `idleMs` | 7 days | | Shortest `idleMs` | 1 minute | | Longest `idleMs` | 365 days | | How long a room's link and short code work | 7 days | | How long a member has the room closed everywhere before `presence` runs | 5 seconds | ### The rules A handler that runs past its CPU time fails as if it had thrown. An event, view, status, or room name past its limit isn't sent or set, and shows as an error on the developer page. A sentence given to `ctx.refuse` past its limit is cut short. A timer set sooner than the shortest delay goes off at that delay. | Limit | Value | | --- | --- | | CPU time to handle one input | 50 milliseconds | | An event from `ctx.emit`, or one member's view | 64 KB | | A member's status | 200 characters | | A room's name, from `ctx.setName` | 60 characters | | The sentence given to `ctx.refuse` | 300 characters | | Shortest delay of a timer set by a `timer` handler or `beat` | 1 second | | Shortest `beatMs` | 50 milliseconds | | Longest `beatMs` | 1 second | ### Notifications `ctx.notify` with a title or text past its length sends nothing and shows as an error on the developer page. Past either rate, Tinylib drops the room's notifications silently. | Limit | Value | | --- | --- | | Title | 80 characters | | Text | 300 characters | | Notifications from one room to one member | 60 per hour | | Notifications from one room | 300 per hour | ### Reminders `tinylib.reminders.set` rejects with `limit` when the user already has the most reminders your app may set, and with `refused` for a field past its length. A reminder that can't go off within its lateness limit, for example because the device was off, skips to its next time. | Limit | Value | | --- | --- | | Reminders a user has waiting, in your app | 32 | | A reminder's name | 64 characters | | A reminder's title | 80 characters | | A reminder's text | 200 characters | | A reminder's address | 100 characters | | How late a reminder still goes off | 1 hour |