Realtime tiles

A shared board. Every open tab sees every click.

Live demo

/apps/tiles in two iframes · paint either panel

Player 1
Player 2

Those are two iframes, which means two separate browsers. A click in one goes to the server. The other frame hears about it from the server, not from the page. You will build this board in one file, about 180 lines, and at the end you will understand every line.

The shape is the live-page pattern. One response stays open and pushes HTML. Short commands change state. The browser library Datastar puts each patch into the page.

1. Make a project#

The board needs a stylesheet and the Datastar script. Copy both from the Stario repository instead of writing them.

uv init --app tiles
cd tiles
uv add "stario>=5,<6"
uv add --dev watchfiles
git clone --depth 1 https://github.com/bobowski/stario.git /tmp/stario
cp -r /tmp/stario/examples/tiles/static .

You now have static/css/style.css and static/js/datastar.js. Everything else goes in main.py. Start the file with the imports.

import asyncio
import random
import uuid
from pathlib import Path
 
from stario import App, Assets, Context, Relay, Route, Span, Writer, classes, h, serve
from stario.datastar import SSE, at, data, read_signals
from stario.markup import HtmlElement, baked, styles

2. Routes, assets, and state#

The app has three routes. GET / serves the page once. GET /subscribe stays open and pushes the board. POST /click paints one cell.

ASSETS = Assets(Path(__file__).parent / "static", "/static")
STYLE_CSS = ASSETS.href("css/style.css")
DATASTAR_JS = ASSETS.href("js/datastar.js")
 
HOME = Route("GET /")
SUBSCRIBE = Route("GET /subscribe")
CLICK = Route("POST /click")
 
COLORS = ("#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6")

Assets serves the static directory with content-hashed URLs. href() at import time gives you the final URL for each file, so the views can use STYLE_CSS as a constant.

The board itself is one class. It holds the painted cells and the players who have an open stream. Painting the same colour twice clears the cell, which gives you undo for free.

class Game:
    def __init__(self, size: int = 5):
        self.size = size
        self.board: dict[int, str] = {}
        self.players: dict[str, str] = {}
 
    @property
    def cells(self) -> int:
        return self.size * self.size
 
    def join(self, user_id: str) -> None:
        self.players.setdefault(user_id, random.Random(user_id).choice(COLORS))
 
    def leave(self, user_id: str) -> None:
        self.players.pop(user_id, None)
 
    def paint(self, user_id: str, cell: int) -> None:
        color = self.players[user_id]
        if self.board.get(cell) == color:
            del self.board[cell]
        else:
            self.board[cell] = color

Nothing here knows about HTTP. That is on purpose: commands change a Game, and views read one.

3. Views#

A view is a function from state to HTML. The same home_view renders the first page and every later patch, so the page always shows what the server knows.

@baked
def page(body: HtmlElement):
    return h.HtmlDocument(
        {"lang": "en"},
        h.Head(
            h.Meta({"charset": "UTF-8"}),
            h.Title("Tiles"),
            h.Link({"rel": "stylesheet", "href": STYLE_CSS}),
            h.Script({"type": "module", "src": DATASTAR_JS}),
        ),
        h.Body(body),
    )
 
 
def cell_view(cell: int, game: Game) -> HtmlElement:
    color = game.board.get(cell)
    return h.Div(
        {"data-cell-id": str(cell)},
        classes("cell", "painted" if color else None),
        styles({"background-color": color}) if color else None,
    )
 
 
def board_view(game: Game) -> HtmlElement:
    return h.Div(
        {"id": "board", "class": "board"},
        data.on(
            "click",
            f"""
            let id = evt.target.dataset.cellId;
            if (id) {{ @post(`{CLICK.href()}?cell=${{id}}`); }}
            """,
        ),
        [
            h.Div(
                {"class": "row"},
                [cell_view(row * game.size + col, game) for col in range(game.size)],
            )
            for row in range(game.size)
        ],
    )
 
 
def swatch(color: str) -> HtmlElement:
    return h.Span({"class": "swatch"}, styles({"background-color": color}))
 
 
def players_view(user_id: str, game: Game) -> HtmlElement:
    return h.Div(
        {"id": "info", "class": "info-panel"},
        h.Div(
            {"class": "info-row"},
            h.Span({"class": "info-label"}, "Players"),
            h.Ul({"class": "users-list"}, [h.Li(swatch(color)) for color in game.players.values()]),
        ),
        h.Div(
            {"class": "info-row"},
            h.Span({"class": "info-label"}, "Your color"),
            swatch(game.players.get(user_id, "#ccc")),
        ),
    )
 
 
def home_view(user_id: str, game: Game) -> HtmlElement:
    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"),
            players_view(user_id, game),
            board_view(game),
        )
    )

Three things in home_view do the realtime work. data.signals stores the tab's user_id in the page, and if_missing=True keeps it across patches. data.init(at.get(...)) opens GET /subscribe when the page mounts and keeps it open; retry="always" reconnects after a network blip. The data.on("click", ...) on the board sends POST /click with the cell id in the query string. Datastar adds the page's signals to every request, so the server knows which tab clicked.

@baked freezes the document shell at import time. Only body is rendered per request.

4. Handlers#

Every handler is async def handler(c, w). These three are factories that take the shared Game and Relay and return the handler, so there are no module globals.

async def read_user_id(c: Context) -> str:
    signals = await read_signals(c.req)
    return str(signals.get("user_id", ""))
 
 
def home(game: Game):
    async def handler(c: Context, w: Writer) -> None:
        user_id = uuid.uuid4().hex[:8]
        w.html(home_view(user_id, game))
 
    return handler

home mints an id for the tab and sends the page. One response, finished.

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:
            w.redirect(HOME.href())
            return
 
        async with relay.subscribe("*") as live:
            sse = SSE(w)
            game.join(user_id)
            relay.publish("join", user_id)
            sse.patch_elements(home_view(user_id, game))
            async for _subject, _payload in c.alive(live):
                sse.patch_elements(home_view(user_id, game))
 
        game.leave(user_id)
        relay.publish("leave", user_id)
 
    return handler

subscribe is the open response. It subscribes to the Relay first, so no event is missed, then joins the board and sends the first patch. After that it waits. Each time anyone publishes, the loop wakes and sends the whole page again. c.alive(live) ends the loop when the tab closes or the server shuts down; then the player leaves and the other tabs are told.

def click(game: Game, relay: Relay[str]):
    async def handler(c: Context, w: Writer) -> None:
        user_id = await read_user_id(c)
        cell = c.req.query.get("cell")
        if user_id not in game.players or cell is None or not cell.isdigit():
            w.empty(400)
            return
        if int(cell) >= game.cells:
            w.empty(400)
            return
 
        w.empty()
        game.paint(user_id, int(cell))
        relay.publish("click", user_id)
 
    return handler

click is the command. It validates, replies with an empty 204, and only then changes the board and publishes. The reply does not contain the new board. The board arrives on every tab's open stream, including the tab that clicked.

5. Bootstrap#

bootstrap creates the one Game and the one Relay, attaches the static files, and registers the three routes.

async def bootstrap(app: App, span: Span):
    game = Game()
    relay = Relay[str]()
    await ASSETS.attach(app)
    app.add(HOME, home(game))
    app.add(SUBSCRIBE, subscribe(game, relay))
    app.add(CLICK, click(game, relay))
    yield

serve is an async generator that builds the App, runs bootstrap, and listens. asyncio.run drives it from the command line:

if __name__ == "__main__":
    asyncio.run(serve(bootstrap))

6. Run it#

uv run watchfiles 'python main.py' .

Open http://127.0.0.1:8000 in two windows side by side. Paint in one. The other changes.

Now open the network tab in one window. You see one short GET /, then one GET /subscribe that never finishes, then a short POST /click for each paint. Each click makes the subscribe request grow by one event. That is the whole mechanism: one open response and many short commands.

In the terminal, each request is a span. The subscribe span stays open as long as the tab does.

7. Make it yours#

Add a reset button. Register RESET = Route("POST /reset"), write a command handler that empties game.board and publishes, and add h.Button(data.on("click", at.post(RESET.href())), "Reset") to home_view. The subscribe loop needs no change; it already re-renders on every publish.

By the way#

  • The board starts empty. The demo at the top and the example in the repository pre-fill it, count painted cells, and add a debug inspector, so their main.py is longer than yours. Read it at examples/tiles/main.py.

  • Relay is in-process. Several server processes need a broker such as Redis in its place; the handler shape stays the same.

  • Sending the whole page on each change is deliberate. It is simple, Datastar morphs only what differs, and the writer compresses the stream, so repeated markup costs little. Narrow the patch with selector= only when you have measured a reason to.

  • c.span.event("Cell toggled", {"cell": cell}) in click adds the event to the request trace. Try it and watch the terminal.

Next: Chat room takes the same three jobs into a project with features, SQLite, and tests.