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
- Size: 1000 × 1000 cells (server/config.go → CanvasWidth / CanvasHeight).
- Background: solid black
0x000000for every unpainted cell. - Palette: 19 colours drawn from server/config.go → DefaultPalette. Anything else is refused with a short reason.
- Cooldown: 5 seconds per signed-in account (server/config.go → Cooldown). The server is authoritative; the client mirrors the countdown but defers to a
Wrapper_Error.retry_after_msreply. - Rate limit: 10 placements per second per connection, with a 20-message-per-second inbound ceiling above that (server/config.go → RateLimitMax / MessageRateMax).
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:
GET /save.png— once-a-minute PNG snapshot written bySaveGame(server/save.go). Carries a strongETag; pollers see 304 until the next snapshot.GET /canvas.png— the live in-memory bitmap, re-encoded on every request. Reflects placements within the broadcast window, not the disk snapshot.GET /canvas.svg— the same live bitmap rendered as SVG, with one<rect>per non-default pixel (server/canvas_svg.go). The wire shape is open and streamable.
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
GET /profile— the signed-in painter's profile page (web/profile.html), listing recent placements read fromGET /api/history.GET /api/me— the current signed-in user.GET /api/me/pixels— the lifetime pixel total for the current user.GET /stats/leaderboard— the top 10 painters in the last 24 hours.GET /stats/leaderboard/all-time— the lifetime painter ranking.GET /stats/canvas— canvas-level counters (placed total, lifetime cells, hot regions).GET /stats/pixels— placements in the last 24 h.GET /api/recent— the most recent 50 accepted placements, newest-first, each with its colour (server/config.go → RecentPixelsLimit). The canvas page lists the newest ten on screen — coordinates and colour swatch — and updates them live as placements arrive.
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.