Pixel Board

A paintable pixel canvas, made for multiplayer: the server owns the cells.

zweiundeins <sb-pixel-board> canvas pixel paint multiplayer realtime Since 2026-09-210Open in playground Edit on GitHub

Playground

A grid of chunky pixels to paint on, with a 16-colour palette. It's built for server-driven multiplayer: the board state is one attribute, cells (one hex digit per cell, row by row), which the server renders. Painting doesn't change cells. It emits sb-paint, your backend applies it, and the next server frame brings the new cells to everyone. While a pixel is in flight, it's drawn slightly faded.

Examples

Local mode

local paints the board's own buffer, so no server is needed. Drag to draw, pick a colour below, or focus the board and use the arrow keys plus Space.

<sb-pixel-board local grid size="24" style="--sb-pixel-board-size: 20rem"></sb-pixel-board>

Server-driven (the Showcase board)

This is the wiring the multiplayer board on the Showcase page uses. The server renders cells into every frame of the page's render stream, and painting is a command:

<sb-pixel-board id="board" size="48" cells="…rendered by the server…"
  data-on:sb-paint="@post('/cmd/paint', {payload: {tabid: $tabid, color: evt.detail.color, cells: evt.detail.cells}})">
</sb-pixel-board>

No data-preserve-attr here: cells belongs to the server, and every morph brings the latest board.

Watch only

<sb-pixel-board readonly size="16" cells="0000000000000000000000055000000000000055550000000000005775000000000005579550000000000559955000000000055775500000000005555550000000000566665000000000f555555f0000000ff555555ff000000f05333350f0000000003333000000000000eddd0000000000000ee00000000000000000000000" style="--sb-pixel-board-size: 12rem"></sb-pixel-board>

Event

sb-paint bubbles out of the shadow root with detail: { color, cells }, where cells are the indices (y * size + x) touched since the last event. Events are batched every ~80 ms during a stroke, and fast drags are interpolated so strokes have no gaps.

Accessibility

The canvas is focusable: the arrow keys move a highlighted cursor, and Space or Enter paints. Its label always states the cursor position, that cell's colour and the selected colour. The palette is a radio group of labelled buttons.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag. The autoloader imports each component the first time its tag appears, including tags added later by a Datastar morph.

<!-- Once per page: Datastar with Rocket, and the Starbase autoloader.
     It loads every <sb-…> component the first time its tag appears. -->
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://rocket.libretto.ch/c/autoloader.js"></script>
<!-- Optional, no flash of undefined elements: class="sb-cloak" on <html>, and -->
<style>.sb-cloak :not(:defined) { visibility: hidden }</style>

<sb-pixel-board local readonly size="16" cells="0000000000000000000000055000000000000055550000000000005775000000000005579550000000000559955000000000055775500000000005555550000000000566665000000000f555555f0000000ff555555ff000000f05333350f0000000003333000000000000eddd0000000000000ee00000000000000000000000" style="--sb-pixel-board-size: 7.5rem"></sb-pixel-board>

<!-- Or skip the autoloader and load just this component: -->
<!-- <script type="module" src="https://rocket.libretto.ch/c/pixel-board/pixel-board.js"></script> -->

API reference

Props

AttributeTypeDefaultDescription
sizenumber48Cells per side.
cellsstring""Board state: one hex digit (palette index) per cell, row by row. Usually server-owned.
palettearray[ "#080D1D", "#1B1F3B", "#283552", "#7785A8", "#AEBBDD", "#F3F4FA", "#8C6BFF", "#B09AFF", "#5B4FD6", "#65BFFF", "#2F7BE0", "#6EF59A", "#4CC46A", "#FFD84D", "#FF9F43", "#E5484D" ]16 colours as a JSON array of #rrggbb.
colornumber7Initially selected palette index.
localbooleanfalsePaint the local buffer directly (offline mode). Otherwise painted cells stay pending until cells echoes them.
readonlybooleanfalseWatch only: no painting, no palette.
gridbooleanfalseShow faint cell lines.

Events

NameDescription
sb-paintPainted cells, batched every ~80 ms during a stroke. detail: { color, cells: [index…] }.