# 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