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.
Apps made with npx tinylib-sdk init already have the package. Otherwise, install it:
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.
The example is a daily hard sudoku.
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:
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.
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:
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:
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:
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.
countdown(fn) calls fn every second with the milliseconds left until the user's midnight, then with 0 when the date changes:
streak() counts the days in a row with a saved result:
If a failed day should break the streak, save only solved puzzles.
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:
For friends' results without the ranking, use friendsWith(key).
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:
available is false offline. If set fails, fn gets the reminder as it was, so the toggle flips back.
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.
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):
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:
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.
Here is tic-tac-toe, whole:
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:
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:
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:
A player who runs out of time three times in a row is out, and strikes changes that. warn reminds them first.
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.
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:
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.
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.
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:
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.
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.
In rules.js, the game is a kind. This excerpt has the parts the helpers rely on:
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.
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:
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:
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).
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:
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.
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:
An invitation shows another user, so it needs Block and Report.
followGame(id, fn) opens the game, calls fn whenever it changes, and returns a function that closes it:
view: what the rules'viewreturned for this user, here{ board, turn, winner, started, you, round }.room: the open room to act in. Itsendedturns 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.
A resign action makes the other player the winner and calls finish, which keeps the final board:
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:
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:
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:
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.
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:
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.
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:
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.
In npx tinylib-sdk dev, start a game in one pane, invite the simulated user in another, and play both seats.