/apps/tiles in two iframes (same Tiles app as two tabs) · paint either panel
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 tilescd tilesuv add "stario>=5,<6"uv add --dev watchfilesgit clone --depth 1 https://github.com/bobowski/stario.git /tmp/stariocp -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 asyncioimport randomimport uuidfrom pathlib import Path from stario import App, Assets, Context, Relay, Route, Span, Writer, classes, h, servefrom stario.datastar import SSE, at, data, read_signalsfrom stario.markup import HtmlElement, baked, styles2. 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] = colorNothing 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.
@bakeddef 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 handlerhome 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 handlersubscribe 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 handlerclick 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.
Warning. Once SSE(w) has written to a Writer, do not call w.html, w.redirect, or w.empty on that same writer. A response can finish only once.
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)) yieldserve 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.pyis longer than yours. Read it atexamples/tiles/main.py.Relayis 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})inclickadds 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.