One board that every visitor paints at once. A click in one tab shows in every other tab.
Play: tiles.stario.dev · Build it step by step: the Realtime tiles tutorial
Tiles is a small, complete Stario app. It is the app the tutorial builds in one file, laid out as a real project: one page, tests, and a deploy on Yard.
The page is live: one response stays open and pushes HTML, and short commands change the state. Three routes do three jobs.
GET / the page, once
GET /live stays open; sends the whole page again after each change
POST /click paints one cell; answers 204, and the new board arrives on /live
GET /renders the board and gives the tab an id in a Datastar signal. On load, the page opensGET /live.GET /livesubscribes to an in-processRelay, sends the board, and waits. Each time anything publishesboard.changed, it renders the page again and sends it. Datastar morphs only what differs.POST /clickreads the cell, validates it, answers an empty 204, paints the cell, and publishesboard.changed. It never sends HTML. Every tab, including the one that clicked, gets the new board from its own stream.
A full board celebrates for four seconds, then a new round starts. A loop on a timer counts down and starts the round. It waits on app.shutdown between passes, so a deploy never waits for it.
Read it in this order. Each file has one job.
app/
main.py bootstrap: builds the Game and the Relay, registers the page, starts the round loop
config.py every setting, read from the environment
common/shell.py the <html> document around every page
common/framing.py Framing: lets the sites in FRAME_ANCESTORS show the board in an iframe
features/board/
urls.py the three routes
data.py the Game: the board and the players, with no HTTP in it
views.py a Game in, HTML out
handlers.py TilesPage, one method per route, and the round loop
tests/test_board.py the page loads, a click repaints two tabs, bad input gets 400
static/ the stylesheet and Datastar
A few habits worth copying:
- State lives on the server. The browser holds one signal, the tab's
user_id. Everything else is rendered from theGame. - One render for everything.
render(user_id, game)builds the first page and every patch. There is no second code path for updates. - A command never answers with HTML. Read, validate, reply, then change the state and publish. Bad input gets
400with a reason. - Subscribe before the first render, so no change falls between them.
- No module globals.
bootstrapbuilds the shared objects and hands them to the page class.
You need uv and Python 3.14.
git clone https://github.com/stariodev/tiles
cd tiles
uv sync
uv run python -m app.mainOpen http://127.0.0.1:8000 in two windows side by side and paint in one. With just, just dev reloads on every change, and just check formats, lints, type-checks, and runs the tests.
On a host with Yard, one command deploys this repo and serves it over HTTPS:
yard app new --repo https://github.com/stariodev/tiles --caddy tiles.example.comYard sees a pyproject.toml that lists stario and a uv.lock, writes the container file, and starts python -m app.main. For your own copy, fork the repo and run yard app new --caddy tiles.example.com in your checkout. Each push then deploys.
| Setting | Default | What it does |
|---|---|---|
FRAME_ANCESTORS |
empty | Sites that may show the board in an iframe, separated by spaces, such as https://stario.dev. Empty means none. |
PUBLIC_URL |
https://tiles.stario.dev |
The address this copy is served at. The share preview (the title, text, and picture a chat shows for a link) needs absolute links. Set it to your own address. |
- The board lives in memory. A restart starts a new round, and that is fine for a game. For data that must survive a restart, see the chat room tutorial, which uses SQLite.
Relayis in-process. One server process holds every tab. Several processes need a broker such as Redis in its place; the handlers keep their shape.- Sending the whole page on each change is deliberate. It is simple, Datastar morphs only what differs, and the stream is compressed.
Add a reset button: a RESET = Route("POST /reset"), a reset method on TilesPage that replies 204, calls game.new_round(), and publishes board.changed, and a button in board_view with data.on("click", at.post(RESET.href())). The stream needs no change; it already repaints on every publish.
MIT