Realtime tiles

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

bash
git clone https://github.com/bobowski/stario.git
cd stario/examples/tiles
uv sync

If you are in the Wratilabs monorepo, cd projects/stario/examples/tiles instead.

Run the app with:

bash
uv run stario watch main:bootstrap

For 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:

  1. Open the grid in two browser tabs.

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

python
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))
    yield

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

python
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:

python
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:

python
@baked
def 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:

python
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:

python
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:

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

python
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 handler

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

python
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 handler

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

10. Long-lived handler: subscribe (factory)

python
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 handler

Subscribe 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:

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

python
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 handler

Use 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():

python
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):

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.