How it works

The canvas is a single 1000 × 1000 bitmap. Every signed-in painter can place one pixel at a time; the server replies with the wire frame that other painters will see a moment later.

The canvas

Live sync over WebSocket

Open /ws to join. The wire is protobuf (proto/msg.proto) wrapped in msg.Wrapper; each frame carries exactly one of Claim, Claimed, Ping, Error, Join, UserList, Notice, Cursor, CursorUpdate, Line, Fill or Overwritten. A successful placement echoes back as a Claimed on every connected socket (including the placer's), which is how two painters see the same pixel land at the same time.

The server keeps a 10 000-frame catch-up buffer (server/config.go → CatchUpBufferSize) so a freshly-reconnected painter receives the placements they missed while disconnected, replayed in order.

Putting a pixel on the canvas

Client side, the call is one protobuf encode and one WebSocket send. sendClaim() in web/js/ws.js:

function sendClaim(x, y, c) {
  if (!socket || socket.readyState !== WebSocket.OPEN) return;
  if (typeof cooldown !== 'undefined' &&
      cooldown && cooldown.active()) return;

  let claimData = { x: x, y: y, color: parseInt(c, 16) };
  const claim    = msg.Claim.create(claimData);
  const wrapper  = msg.Wrapper.create({ claim });
  const bytes    = msg.Wrapper.encode(wrapper).finish();
  socket.send(bytes);

  lastClaim = { x: x, y: y, color: claimData.color };
}

The server handler (server/main.go, the Wrapper_Claim case) bounds-checks against CanvasWidth / CanvasHeight, refuses anything outside the configured protected region, refuses non-palette colours, charges the cooldown, and then broadcasts a Wrapper_Claimed with the painter's id and a Unix-second timestamp.

The 19-colour palette

The palette is a single list of 0xRRGGBB values the handler enforces before any other gate. The current DefaultPalette:

0x000000, 0xFFFFFF, 0x7F7F7F, 0xC0C0C0, 0xFF0000,
0x00FF00, 0x0000FF, 0x880015, 0xED1C24, 0xFF7F27,
0xFFF200, 0x22B14C, 0x00A2E8, 0x3F48CC, 0xA349A4,
0xB97A57, 0xFFAEC9, 0x99D9EA, 0x7092BE

An operator can edit the live palette through /api/palette; the client re-fetches on every page load.

Templates, line, fill

A template is a 1000 × 1000 SVG (for example web/templates/heart.svg) the painter can overlay on the canvas to coordinate a group draw. Show template in the toolbar toggles it; web/js/template.js handles the pan/zoom alignment so the template stays locked to the canvas grid.

A line (msg.Line) is one action that paints every cell on the rasterised straight line between two endpoints. A fill (msg.Fill) is one action that recolours every cell in the 4-connected same-colour region reachable from a starting cell. Both charge one cooldown and one rate-limit token for the whole action, no matter how many cells it covers.

Zoom and pan

Zoom is controlled by the wheel, the + / − buttons, or the keyboard. The math lives in web/js/zoom.js (stepAmount / clamp) and is shared with the toolbar buttons via the view.scaleAt() entry point. The whole canvas is visible from zoom 0.25× to 16×.

Export the canvas

Three endpoints serve the bitmap, each with a different cache shape:

A signed-in painter can also crop a region through the operator-only GET /api/region.png?x=&y=&w=&h= endpoint, gated on the same X-Operator-Token header the moderation actions use.

Profile, history, leaderboards

Sign in

Sign-in is Google OIDC. The Sign in with Google button in the canvas chrome (or on /profile) starts the flow; the callback exchanges the auth code for a session cookie, and the cookie is what every signed-in endpoint reads.

Anonymous placements are not stored in any feed, so they will not show up in your history or the leaderboards — only signed-in placements do.

Looking for a specific endpoint? The full surface is documented in the project README — the same file that ships with the source.