Realtime tiles
Prefer the interactive walkthrough?
Getting started embeds the live Tiles demo with line-numbered source excerpts — same example, less scrolling.
This is a walkthrough of the tiles example (examples/tiles). It is written to be fairly verbose on purpose: the goal is to show how core ideas for realtime apps come together in Stario with Datastar—shared state, one long-lived update channel, short commands over POST, and HTML pushed from Python.
If you have not worked through Hello world yet, skim it first—you should already be comfortable with bootstrap, UrlPath, data/at/SSE, and read_signals.
1. Create the project
The example ships CSS, a vendored Datastar script, and a layout so you can focus on behaviour instead of wiring assets from zero.
Clone the stario repo (or copy examples/tiles into your workspace):
git clone https://github.com/bobowski/stario.gitcd stario/examples/tilesuv syncIf you are in the Wratilabs monorepo, cd projects/stario/examples/tiles instead.
Run the app with:
uv run stario watch main:bootstrapFor a one-shot run without reload: uv run stario serve main:bootstrap.
Open http://127.0.0.1:8000 (set STARIO_PORT if the default is busy).
2. Play with it first
Before opening main.py:
Open the grid in two browser tabs.
Paint cells in one tab—the other should update without a full reload.
What you are seeing: one normal page load (GET), then server-driven HTML updates on a long-lived connection (SSE), while clicks go out as short POSTs. That split is the pattern this tutorial unpacks.
Optionally keep the server terminal visible while you click—you will see request activity line up with what happens in the UI.
3. Commands vs queries (CQRS-shaped)
See The go-to architecture for the full picture. In short: queries answer “what should the UI show?” (HTML + SSE patches); commands apply user intent (POST routes that change state). This template wires that as different handlers, not a CQRS product—Relay is just the in-process nudge so that after a command mutates state, every open /subscribe loop re-reads server state and streams fresh HTML.
Flow: GET / returns the document once. GET /subscribe stays open; when something calls relay.publish, the subscribe handler rebuilds the view from Game and pushes patches. POST /click returns 204—the browser does not get new HTML there; updates only appear on the SSE connection. Datastar morphs streamed HTML into the live DOM.
Until /subscribe has registered the tab’s user, click may not apply—the handler redirects home first. Swap in a real database when you outgrow in-memory Game.
4. Bootstrap
Open main.py and find bootstrap(app, span). This function is where the app is wired for startup: static files, routes, shared state. Stario runs everything before yield once at the beginning of the process; code after yield runs on shutdown.
The tiles template uses this shape:
async def bootstrap(app: App, span: Span): game = Game() relay = Relay[str]() span.attrs( { "tiles.grid_size": game.grid_size, "tiles.total_cells": game.total_cells, "tiles.color_count": len(game.colors), "tiles.static_dir": str(ASSETS.directory), } ) with span.step("static_assets") as s: static = StaticAssets(ASSETS) s.attrs(static.stats) static.register(app) app.get(HOME, home(game)) app.get(SUBSCRIBE, subscribe(game, relay)) app.post(CLICK, click(game, relay)) yieldspan.attrs and span.step record startup metadata for your telemetry sinks. Game and Relay are created here—not as module globals—so one process owns one board and one fan-out bus.
Teardown: the yield separates startup from shutdown. Stario treats that shape like an async context manager—you do not wire the lifecycle by hand.
Static assets and routes
At import time, the example builds an AssetManifest and resolves fingerprinted URLs:
ASSETS = AssetManifest(Path(__file__).parent / "static")STYLE_CSS = ASSETS.href("css/style.css")DATASTAR_JS = ASSETS.href("js/datastar.js") HOME = UrlPath("/")SUBSCRIBE = UrlPath("/subscribe")CLICK = UrlPath("/click")StaticAssets(ASSETS).register(app) serves those files with compression and cache headers. In HTML you never hard-code the hashed filename—you use STYLE_CSS and DATASTAR_JS constants built from ASSETS.href(...).
Routes use the same UrlPath constants in app.get / app.post and in views via .href().
5. State: the Game class
Instead of scattered module-level board and users dicts, tiles keeps authoritative state on a Game instance:
class Game: def __init__(self, *, grid_size: int = 5, colors: tuple[str, ...] | None = None): # board: cell index → color # user_colors: user_id → color (only tabs with an open /subscribe) ... def join(self, user_id: str) -> None: ... def leave(self, user_id: str) -> None: ... def paint_cell(self, user_id: str, cell_id: int) -> str: ...Commands mutate Game; queries read Game and pass it into views. Handler factories (home(game), subscribe(game, relay), click(game, relay)) close over the single instance from bootstrap—no globals, no dependency injection framework.
6. HTML in Stario
Tags come from from stario.markup import html as h. Each tag is callable: mappings merge into attributes; other positional args are children. classes(...) and styles(...) accept variadic tokens and mapping dicts; None/False tokens are skipped so conditional markup stays readable. See HTML reference.
The document shell is frozen at import with @baked—only the body slot changes per request:
@bakeddef page(body: HtmlElement): return h.HtmlDocument( {"lang": "en"}, h.Head( h.Meta({"charset": "UTF-8"}), h.Title("Tiles - Stario App"), h.Link({"rel": "stylesheet", "href": STYLE_CSS}), h.Script({"type": "module", "src": DATASTAR_JS}), ), h.Body(body), )The main view wires signals, the live subscription, and the board:
from stario.datastar import at, data def home_view(user_id: str, game: Game): return page( h.Div( {"id": "home", "class": "container"}, data.signals({"user_id": user_id}, if_missing=True), data.init(at.get(SUBSCRIBE.href(), retry="always")), h.H1("Tiles - Stario App"), info_view(user_id, game), board_view(game), ), )data.signals(..., if_missing=True) merges defaults only when a key is missing, so reconnects do not wipe client-held signal values.
data.init(at.get(...)) becomes a data-init attribute. In Datastar, init runs when the node mounts—here it opens GET /subscribe with retry="always" so brief network blips do not kill the stream.
The same user_id is read in subscribe and click via a thin read_user_id(c) wrapper around read_signals(c.req) — see main.py in the example.
Snippets on this page are abbreviated vs the full main.py (viewport meta, info_view, span attrs, and partial board markup are omitted).
Board cells use data.on so a click issues a @post to /click with cellId in the query string:
def board_view(game: Game): return h.Div( {"id": "board"}, data.on( "click", f""" let id = evt.target.dataset.cellId; if (id) {{ @post(`{CLICK.href()}?cellId=${{id}}`); }} """, ), # rows of cell_view(...) )7. Handler shape: Context and Writer
Handlers always look like:
async def handler(c: Context, w: Writer) -> None: ...c is the per-request context: c.req, c.span, c.route, and c.state for middleware.
w is the response side: status, headers, body, streaming, SSE. The helpers in stario.responses and stario.datastar.SSE know how to finish a response correctly.
For long-lived work, prefer c.alive() and c.closing over polling the writer directly (Handlers and the writer).
8. First handler: home (factory)
def home(game: Game): async def handler(c: Context, w: Writer) -> None: user_id = str(uuid.uuid4())[:8] c.span.attr("user_id", user_id) responses.html(w, home_view(user_id, game)) return handlerThis is normal request/response: mint a per-tab id, build HTML once, send it with responses.html. The factory pattern lets bootstrap inject game without globals.
9. Command handler: click (factory)
click validates signals and cellId, returns 204 immediately, then mutates Game and publishes so every subscribe loop pushes a fresh home_view.
def click(game: Game, relay: Relay[str]): async def handler(c: Context, w: Writer) -> None: user_id = await read_user_id(c) if user_id not in game.user_colors: responses.redirect(w, HOME.href()) return # validate cellId ... c.span.attrs({"user_id": user_id, "cell_id": cell_id}) responses.empty(w, 204) action = game.paint_cell(user_id, cell_id) c.span.event("Cell toggled", {"cell_id": cell_id, "action": action}) relay.publish("click", user_id) return handlerThe 204 is “the request was accepted,” not “every side effect succeeded.” Heavier work can run after the 204 in the same coroutine or via app.create_task.
After you have started an SSE response on a given Writer, do not call responses.html, redirect, or empty on that same writer—the runtime rejects double completion.
10. Long-lived handler: subscribe (factory)
def subscribe(game: Game, relay: Relay[str]): async def handler(c: Context, w: Writer) -> None: user_id = await read_user_id(c) if not user_id: responses.redirect(w, HOME.href()) return async with relay.subscribe("*") as live: sse = SSE(w) game.join(user_id) relay.publish("join", user_id) c.span.event("Player connected", {"user_id": user_id}) sse.patch_elements(home_view(user_id, game)) async for subject, _ in c.alive(live): c.span.event("relay", {"subject": subject}) sse.patch_elements(home_view(user_id, game)) game.leave(user_id) relay.publish("leave", user_id) c.span.event("Player disconnected", {"user_id": user_id}) return handlerSubscribe before publishing so this client’s queue exists. c.alive(live) stops the loop when the tab disconnects or the server shuts down. After the async with, presence is removed and leave is published so other tabs update.
11. Try it: reset the board (small extension)
The stock template does not include this; it is a concise exercise that matches the same command → relay → subscribe pattern.
Add a route constant and register it in bootstrap:
RESET = UrlPath("/reset") app.post(RESET, reset_board(game, relay))Implement the handler like click: validate user_id and membership, return 204 quickly, mutate shared state, then publish so SSE clients refresh from source of truth.
def reset_board(game: Game, relay: Relay[str]): async def handler(c: Context, w: Writer) -> None: user_id = await read_user_id(c) if user_id not in game.user_colors: responses.redirect(w, HOME.href()) return responses.empty(w, 204) game.board.clear() relay.publish("reset", user_id) return handlerUse any relay topic string you like; subscribe already listens on "*", so "reset" will wake the same loops as "click".
In home_view, wrap the subtitle and reset button in a row, then add the button with data.on and @post to RESET.href():
h.Div( {"class": "subtitle-row"}, h.P( {"class": "subtitle"}, "Click cells to paint. Everyone sees changes live!", ), h.Button( {"type": "button", "class": "reset-btn"}, data.on("click", f"@post(`{RESET.href()}`);"), "Reset board", ),),Add styles that reuse the template’s variables so the button matches the warm, light UI (drop this into static/css/style.css):
.subtitle-row { display: flex; flex-direction: column; align-items: center; gap: 0.75rem;} .reset-btn { padding: 0.4rem 0.85rem; font-size: 0.8rem; font-weight: 600; border-radius: var(--radius); border: 1px solid var(--border-strong); background: var(--surface); color: var(--fg); cursor: pointer;} .reset-btn:hover { background: var(--surface-hover); border-color: var(--accent);}After a reset, every connected tab should see an empty board without reloading.
Where to go next
When this file grows unwieldy, Structuring larger applications shows how to split files. For a full multi-file layout with SQLite and tests, clone examples/chat-room. For typed signal bodies and reusable patch_* helpers, see Datastar: Reading and writing signals.