Tinylib
DiscoverDocsSign in
Sign in
Tinylib
AboutDiscoverDocsMake an appPricingTermsPrivacyDeveloper TermsContact

Start

  • Quickstart
  • How Tinylib works

Guides

  • Data
  • Rooms and rules
  • Games
    • Import the helpers
    • Build a daily puzzle
    • Build a solo game
    • Define a turn game
    • Play with no signal
    • Build a game with friends
  • Friends
  • Places and links
  • Reminders and notifications
  • Publishing
  • Errors and offline
  • Platforms

Client API

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

Rules API

  • Kinds
  • ctx

Reference

  • tinylib.json
  • Command line
  • Limits

Plain text

  • llms.txt
  • llms-full.txt

Games

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:

Terminal
npm install --save-dev tinylib-sdk
app.js
import { clock, countdown, daily, followDailyReminder, leaderboard, seeded, shuffled, stopwatch, today } from 'tinylib-sdk/kits'

dev and publish add the uses entries the helpers need, like "friends" for daily.

Your rules can import tinylib-sdk/kits too, for what needs no tinylib: 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() returns today's date, like '2026-10-20', and seeded(seed) returns a random number generator that gives the same numbers for the same seed:

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) deals tiles or cards the same way for everyone.

Time the user

stopwatch(onTick) calls onTick every second with the seconds spent, and pauses while the page is hidden. clock writes seconds as '1:23'.

Save the grid and time after each move, so a user who closes the app can carry on:

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) 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) stores the result where friends can read it:

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) reads it back:

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.

Count down to tomorrow

countdown(fn) calls fn every second with the milliseconds left until the user's midnight, then with 0 when the date changes:

app.js
const stopCountdown = countdown((left) => { if (left) nextPuzzle.textContent = `New puzzle in ${clock(Math.ceil(left / 1000))}` else showToday() })

Show a streak

streak() counts the days in a row with a saved result:

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) resolves with the user's and their friends' results for the day's key, best first by compare. valid drops results an older version of your app saved in another shape:

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

Remind the user every day

followDailyReminder(name, options, fn) keeps a toggle on your page in step with a daily reminder. It calls fn now and whenever the reminder changes:

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) keeps a puzzle's games, one for today and one for each practice mode, and the best score in each. Its results are daily(name):

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) makes the list of friends' results, and reminderSwitch(options) makes the daily reminder's switch. Each returns its node to place and a stop to call when the screen closes:

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) compiles a game to a kind. 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:

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:

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:

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:

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) words what leaving would do, for your page to ask first.

Draw it

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

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) 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 in join, stand in leave, and present in presence.

Play with no signal

localRoom(kind, config) 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:

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

Write the rules

In rules.js, the game is a kind. This excerpt has the parts the helpers rely on:

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, 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/<id>. route starts the screen for the current address and stops it when the address changes. Each screen is a function that draws it and returns a function that stops it:

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:

JavaScript
useEffect(() => followGame(id, setGame), [id])

Start a game and invite a friend

Create the game with tinylib.rooms.create('fourInARow') and go to game/<id>. Until a friend joins, the view's started is false, so show a button that calls tinylib.rooms.invite(id).

List the user's games

followGames(kind, fn) calls fn with the user's games of that kind, and again whenever one changes. It returns the stop function route needs:

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:

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.

Play a game

followGame(id, fn) opens the game, calls fn whenever it changes, and returns a function that closes it:

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:

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:

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:

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') }, }, }

Keep a slow game open

A friend who stops playing doesn't leave the game. Tinylib closes 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:

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:

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

Offer a rematch

game.rematch is the other player's rematch invitation, or null. playAgain(game) accepts it, or else starts a new game and invites the other player. It resolves with the { id } of the game to open:

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

PreviousRooms and rulesNextFriends

On this page

  • Import the helpers
  • Build a daily puzzle
  • Deal the day's puzzle
  • Time the user
  • Save the result
  • Count down to tomorrow
  • Show a streak
  • Show friends' results
  • Remind the user every day
  • Try the puzzle in dev
  • Build a solo game
  • Define a turn game
  • Write the game
  • Keep secrets
  • Set a clock
  • Handle players who leave
  • Draw it
  • Share seats without defineGame
  • Play with no signal
  • Build a game with friends
  • Write the rules
  • Switch between the list and a game
  • Start a game and invite a friend
  • List the user's games
  • Show invitations
  • Play a game
  • Resign or leave a game
  • Play again in the same room
  • Keep a slow game open
  • Show a finished game
  • Offer a rematch
  • Try the game in dev