Tinylib
DiscoverDocsSign in
Sign in
Tinylib
AboutDiscoverDocsMake an appPricingTermsPrivacyDeveloper TermsContact

Start

  • Quickstart
  • How Tinylib works

Guides

  • Data
  • Rooms and rules
  • Games
  • Friends
  • Places and links
  • Reminders and notifications
  • Publishing
  • Errors and offline
  • Platforms

Client API

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

Rules API

  • Kinds
    • Kind
    • Handler
    • Relay
    • Status
    • JoinVia
    • LeaveReason
  • ctx

Reference

  • tinylib.json
  • Command line
  • Limits

Plain text

  • llms.txt
  • llms-full.txt

Kinds

Kind

interface Kind<State = any, Options = any, View = unknown>
rules.js exports each kind by name, such as game, as an object holding the handlers that run every room of that kind. Your client makes a room of it with tinylib.rooms.create('game').

Every named export that's an object with a create function is a kind. Tinylib ignores other exports, so you can export helpers too. publish refuses rules with no kind, or with a kind that breaks a rule on this page.

Tinylib runs a room's inputs one at a time, in the order they arrive, each on the state the last one left. Each input runs one handler:

HandlerRuns whenctx.user
create
Runs when
A client calls tinylib.rooms.create.
ctx.user
The creator
actions[name]
Runs when
A member's client calls room.act.
ctx.user
The member who acted
join
Runs when
Someone joins by an invitation or the room's link. Never for the creator.
ctx.user
The new member
leave
Runs when
A member leaves or is removed.
ctx.user
The member who left
presence
Runs when
A member comes into the room or opens it on their first device, or has had it closed everywhere for 5 seconds.
ctx.user
That member
timers[name]
Runs when
A timer set with ctx.setTimer goes off.
ctx.user
null
beat
Runs when
Every beatMs while a member has the room open.
ctx.user
null
migrate
Runs when
Anything happens in the room for the first time under a new version, before that input's handler.
ctx.user
null

create and actions answer a client's call: ctx.refuse rejects the call with refused, and a throw rejects it with failed. The other inputs have already happened, so they can't refuse. If one of them throws, its changes are dropped but the input stands: someone who joined is still a member. Every error shows on your developer page.

After each input that neither refuses nor throws, Tinylib calls view, status, and canInvite for every member with the new state. They get no ctx, and Tinylib may call them again at any time, so compute their result from their arguments alone; changes they make to state aren't kept. If one throws for a member, that member keeps what they had, and the input still stands.

No handler runs when a room ends, whether by ctx.end, by its last member leaving, or by going unused for its idleMs. Record results as they happen.

The rules run under these conditions:

  • Handlers are synchronous. dev and publish refuse an async handler, and a handler that returns a promise fails.
  • The rules can't reach the network: fetch, XMLHttpRequest, and WebSocket aren't there.
  • rules.js can import your own files and npm packages. From tinylib-sdk it can import types and tinylib-sdk/kits: defineGame, the seats, the words, addDays, seeded, shuffled, clock, and today given a date run in rules, and the rest call the page's tinylib. Any other import of tinylib-sdk stops npx tinylib-sdk dev and publish with an error.
  • Keep everything a room needs in state. A module-level variable may last between inputs and be shared between rooms, or it may be lost at any time.
  • Use ctx.now instead of Date.now(), and ctx.random() instead of Math.random().
  • state is saved as JSON after each input, so a Map or an undefined doesn't survive. An input that would leave the state and its timers past 1 MB changes nothing: create and actions reject with limit, and any other handler's changes are dropped and show on your developer page.
  • A handler that runs past 50 milliseconds of CPU time is stopped, and its input fails as if it had thrown.
  • console.log shows in the browser's console in npx tinylib-sdk dev. In a published app it goes nowhere.

To type a kind, write import type { Kind } from 'tinylib-sdk/rules' in rules.ts, or annotate it with @type {import('tinylib-sdk/rules').Kind} in rules.js.

JavaScript
export const counter = { create() { return { count: 0 } }, actions: { add(state) { state.count += 1 }, }, view(state) { return { count: state.count } }, }

Type parameters

<State>optional
The type of the room's state.

Default: any

<Options>optional
The type of the options create receives.

Default: any

<View>optional
The type view returns.

Default: unknown

Properties

addressstringoptional
The path your app opens at when someone comes to a room of this kind from outside the app, like a notification or an invitation. Write it without a leading /. :id becomes the room's id, as in 'game/:id'.

Default: the start screen

idleMsnumberoptional

How long a room of this kind lasts unused, in milliseconds, counted from the last time a member had it open, acted, joined, or left. Timers don't count. It must be from 1 minute to 365 days.

Members keep a room that ends this way in their past rooms, with its last status and its room data.

Default: 7 days

JavaScript
idleMs: 365 * 24 * 60 * 60 * 1000 // a list kept for a year without anyone opening it
JavaScript
idleMs: 60 * 60 * 1000 // a game played in one sitting, closed after an hour unused
offlinetrueoptional
Set to true to let members act in a room of this kind without a connection, as in a shared list. Every member gets the whole state, so the kind has no view and keeps no secrets. The state must fit in 256 KB.
JavaScript
export const list = { offline: true, create() { return { items: [] } }, actions: { add(state, item, ctx) { if (typeof item?.id !== 'string' || typeof item.text !== 'string') ctx.refuse("That item couldn't be added.") if (!state.items.some((each) => each.id === item.id)) state.items.push({ id: item.id, text: item.text, done: false }) }, }, }
guestsbooleanoptional
Set to false to keep people without a Tinylib account out of this kind: they can't create a room of it, or join one by its link or code. By default anyone can. See guests.

Default: true

beatMsnumberoptional
How often beat runs, in milliseconds, for a game that moves in real time. It must be from 50 milliseconds to 1 second, and a kind with beat needs it. For anything slower, set a timer.
JavaScript
beatMs: 100 // ten times a second

Functions

create(options, ctx)
Makes a new room's first state.
actionsRecord<string, Handler<State>>optional
The kind's actions, by name. room.act('move', 4) runs actions.move(state, 4, ctx). A call naming an action the kind doesn't have fails.
timersRecord<string, Handler<State>>optional
The kind's timers, by name. ctx.setTimer('turn', at, data) runs timers.turn(state, data, ctx) at at, with ctx.user null.
JavaScript
timers: { turn(state) { state.turn = state.turn === 'x' ? 'o' : 'x' }, },
beat(state, relays, ctx)optional
Runs every beatMs while at least one member has the room open, with every relay members sent since the last beat, in the order the room took them.
join(state, via, ctx)optional
Runs after someone joins, once they're a member.
leave(state, why, ctx)optional
Runs after a member leaves or is removed.
presence(state, present, ctx)optional
Runs when a member arrives or goes.
migrate(state, fromVersion, ctx)optional
Reshapes a room's state for a new version, so your other handlers see only the new shape.
view(state, member, members)optional
Returns what member sees of the state, which their client gets through room.onView.
status(state, member, members)optional
Returns the line Tinylib shows for this room in member's list of rooms, or null for none.
canInvite(state, member, members)optional
Returns whether member may invite now, with an invitation or the room's link.

Kind.create()

Kind.create(options: Options, ctx: Context): State
Makes a new room's first state. join doesn't run for the creator. A refusal, a throw, or a first state past the room's limits rejects the client's call, and no room is made.
JavaScript
create(options, ctx) { return { board: Array(9).fill(''), players: { x: ctx.user.id, o: null }, turn: 'x', winner: null } },

Parameters

optionsOptions
What the client passed as options to tinylib.rooms.create, or {}. Any user can send anything, so check it before you use it.
ctxContext
The input's Context. ctx.user is the creator, and ctx.members lists only them.

Returns

State
The room's first state. Returning undefined fails the call.

Kind.beat()

Kind.beat?(state: State, relays: Relay[], ctx: Context): void | undefined
Runs every beatMs while at least one member has the room open, with every relay members sent since the last beat, in the order the room took them. The first beat comes beatMs after a member opens the room, and the beats stop once nobody has it open. Positions travel once, as relays: other members draw them at once, and the rules check and keep them here. Send what happens once, such as a hit or a pickup, with room.act.
JavaScript
beatMs: 100, beat(state, relays, ctx) { for (const { from, data: [x, y], at } of relays) { const player = state.players[from] if (player && at > player.at && reachable(player, x, y, at)) Object.assign(player, { x, y, at }) } if (ctx.now >= state.endsAt) state.over = true },

Parameters

stateState
The room's state, to change in place.
relaysRelay[]
The relays since the last beat, in the order the room took them. Each sender's arrive in the order they sent them.
ctxContext
The input's Context. ctx.user is null, and ctx.now is when this beat ran.

Kind.join()

Kind.join?(state: State, via: JoinVia, ctx: Context): void | undefined
Runs after someone joins, once they're a member. It can't turn them away; to stop people joining, return false from canInvite. A full room turns joins away before join runs; the most members a room holds is 64.
JavaScript
join(state, via, ctx) { if (state.players.o === null) state.players.o = ctx.user.id },

Parameters

stateState
The room's state, to change in place.
viaJoinVia
How they joined.
ctxContext
The input's Context. ctx.user is the new member, and ctx.members includes them.

Kind.leave()

Kind.leave?(state: State, why: LeaveReason, ctx: Context): void | undefined
Runs after a member leaves or is removed. When the last member leaves, the room ends after leave.
JavaScript
leave(state, why, ctx) { if (state.winner !== null) return state.winner = state.players.x === ctx.user.id ? 'o' : 'x' ctx.data.room.set('final', { board: state.board, winner: state.winner }) ctx.end() },

Parameters

stateState
The room's state, to change in place.
whyLeaveReason
Why they left.
ctxContext
The input's Context. ctx.user is the member who left, and ctx.members no longer includes them, but ctx.data can still read and write their data.

Kind.presence()

Kind.presence?(state: State, present: boolean, ctx: Context): void | undefined
Runs when a member arrives or goes. Opening the room on another device, or opening it again within 5 seconds of closing it, runs nothing.

Parameters

stateState
The room's state, to change in place.
presentboolean
True when the member comes into the room or opens it on their first device. False once they've had it closed on every device for 5 seconds.
ctxContext
The input's Context. ctx.user is the member who came or went.

Kind.migrate()

Kind.migrate?(state: State, fromVersion: number, ctx: Context): void | undefined
Reshapes a room's state for a new version, so your other handlers see only the new shape. It runs the next time anything happens in the room, before that input's handler, so in a room nobody has used for a while, fromVersion can be several versions old.

Before a version goes live, the room check runs migrate on a copy of every room that hasn't ended.

JavaScript
migrate(state, fromVersion) { if (fromVersion < 4) state.moves = [] // version 4 added moves },

Parameters

stateState
The state as the earlier version's rules left it, to change in place.
fromVersionnumber
The number of the version whose rules last handled an input in this room.
ctxContext
The input's Context. ctx.user is null, and ctx.version is fromVersion.

Kind.view()

Kind.view?(state: State, member: Member, members: Member[]): View
Returns what member sees of the state, which their client gets through room.onView. Every kind but an offline one needs it.

What view leaves out never reaches that member's device, so view is how a game keeps secrets. Tinylib sends a member their view when it changes. A view must be JSON, and one past 64 KB isn't sent.

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

Parameters

stateState
The room's state. Changes to it aren't kept.
memberMember
The member whose view this is.
membersMember[]
Every current member, in the order they joined. Unlike ctx.members, they don't have joinedAt or present, so a view can't show who has the room open.

Kind.status()

Kind.status?(state: State, member: Member, members: Member[]): Status | null
Returns the line Tinylib shows for this room in member's list of rooms, or null for none. A status never sends a notification; use ctx.notify for that. A status whose text is past 200 characters isn't set.
JavaScript
status(state, member) { if (state.winner) return { text: 'Game over', waiting: false } const waiting = state.players[state.turn] === member.id return { text: waiting ? 'Your move' : 'Their move', waiting } },

Parameters

stateState
The room's state. Changes to it aren't kept.
memberMember
The member whose status this is.
membersMember[]
Every current member, as view gets them.

Kind.canInvite()

Kind.canInvite?(state: State, member: Member, members: Member[]): boolean
Returns whether member may invite now, with an invitation or the room's link. Without canInvite, every member may. Returning anything but true or false counts as a throw.

An invitation works while the member who sent it may invite, and the room's link and code work while any member may. Each join is its own input, so a seat that canInvite closes is closed before the next join.

JavaScript
canInvite(state) { return state.players.o === null },

Parameters

stateState
The room's state. Changes to it aren't kept.
memberMember
The member it's asked about.
membersMember[]
Every current member, as view gets them.

Handler

type Handler<State = any, Data = any> = (state: State, data: Data, ctx: Context) => void | undefined
An action's or a timer's handler. It changes state in place; returning a new state fails the input. To start over, assign fresh fields to state, as again does below.

For an action, data is what the member's client passed to room.act, as JSON, or undefined. Any member can send any value, so check it and refuse what you don't accept. For a timer, data is what ctx.setTimer was given.

JavaScript
export const game = { // ... actions: { move(state, square, ctx) { if (!Number.isInteger(square) || square < 0 || square > 8 || state.board[square] !== '') { ctx.refuse('Pick an empty square.') } // ... }, again(state) { Object.assign(state, { board: Array(9).fill(''), turn: 'x', winner: null }) }, }, }

Relay

interface Relay
A relay as beat receives it: who sent it, its numbers as other members got them, and when it was sent.

Properties

fromstring
The sender's id, as in ctx.members.
datanumber[]
The numbers, rounded as room.relay rounds them.
atnumber
When the sender's client says it sent it, on Tinylib's clock, at most 100 milliseconds later than ctx.now. Like data, it comes from the sender's device.

Status

interface Status
What a Kind's status returns for one member.

Properties

textstring
The line Tinylib shows, such as 'Your move'.
waitingboolean
True when the room is waiting for this member to act. Tinylib lists the rooms waiting on the user.

JoinVia

type JoinVia = { invite: Member } | 'link'
How someone joined, as join receives it: { invite } with the member whose invitation they accepted, or 'link' when they used the room's link or code. A guest gets no invitations, so always joins by 'link'.

LeaveReason

type LeaveReason = 'left' | 'removed'
Why a member left a room, as leave receives it.
ValueWhen
'left'
When
The member left, or Tinylib took them out because they deleted their data in your app.
'removed'
When
The rules removed the member with ctx.remove.
Previoustinylib-sdk/kitsNextctx

On this page

  • Kind
  • create()
  • beat()
  • join()
  • leave()
  • presence()
  • migrate()
  • view()
  • status()
  • canInvite()
  • Handler
  • Relay
  • Status
  • JoinVia
  • LeaveReason