Skip to content
Shahabaz Khan
Go back

ChatMural: I built a pixel canvas your stream chat paints together

Edit page

Every streamer eventually hits the same wall: chat is a river of text scrolling past, and there’s no way for a viewer to leave a mark on the stream itself. Polls get old. Channel points get spent on nothing. The viewer who has been lurking for six months has no way to say “I was here.”

So one weekend I built ChatMural — a shared 64×64 pixel canvas that sits on top of the stream. Viewers open a link on their phone, tap a colour, tap a cell, and the pixel appears live on the broadcast. Over an hour, a few hundred people paint a mural together.

It’s r/place, scoped down to one streamer and one session, and rendered directly into OBS.

The ChatMural viewer board: a pixel-art portrait built up on a 64×64 grid, with the streamer tool bar (Nuke, Erase, Lock, Specials, Snapshot) and the sixteen-colour palette below it.

The setup is one URL

The whole streamer onboarding is: log in with Twitch or YouTube, click Activate Canvas, and copy an overlay link into OBS as a Browser Source. That’s it. No plugin, no extension review process, no download.

The overlay page renders with a fully transparent body, so the canvas floats over the gameplay with nothing behind it. It’s read-only at the protocol level — the overlay role is hard-blocked from placing pixels, so nobody can find the OBS URL and start drawing through it.

Viewers get a separate mobile-first board with pinch-zoom, pan, a sixteen-colour palette, and a cooldown timer. Streamers can also upload a reference image that appears underneath the canvas as a tracing guide — visible to viewers on their boards, invisible on the broadcast overlay. Chat traces it in, and the audience watching the stream just sees a mural assembling itself out of nothing.

The stack

This is a TypeScript monorepo on pnpm workspaces, in three parts.

apps/web — Next.js 16, React 19, App Router. Landing page, streamer dashboard, the viewer board at /play/:streamerId, and the OBS overlay at /overlay/:streamerId. Tailwind 4 for layout, hand-written CSS variables for the glassmorphism look. Rendering is a plain HTML5 <canvas> with image-rendering: pixelated — no WebGL, no canvas library. At 64×64 there is nothing to optimise; a full repaint is 4,096 fillRect calls and finishes inside a frame.

apps/server — Fastify 5 and the native ws library. Fastify because it’s meaningfully cheaper per request than Express and its hook system makes auth and rate limiting easy to bolt on. Raw ws instead of Socket.IO because I don’t need rooms or namespaces as a library feature — Redis pub/sub already does fan-out, and Socket.IO’s protocol overhead buys nothing here.

packages/protocol — shared types, zero runtime code. Every WebSocket message is a discriminated union on a type field, imported by both the client and the server. This is the single highest-value decision in the repo. The client physically cannot send a message shape the server doesn’t handle, because it wouldn’t compile.

type ServerMessage =
  | { type: 'state.snapshot';  canvas: string /* base64 */; size: number }
  | { type: 'pixel.update';    x: number; y: number; color: number }
  | { type: 'pixels.batch';    pixels: PixelUpdate[] }
  | { type: 'region.update';   x: number; y: number; w: number; h: number; pixels: string }
  | { type: 'cooldown.tick';   remainingMs: number }
  | { type: 'error';           code: string; message: string };

The canvas is 4 KB of Redis

The part I’m happiest with. The obvious way to store a pixel canvas is a hash keyed by "x:y". Don’t do that.

A 64×64 canvas with a 16-colour palette needs exactly one byte per cell — the palette index. That’s a 4,096-byte binary blob, and Redis can mutate one byte of it in place:

SETRANGE canvas:{streamerId} <offset> <byte>

O(1) per pixel placement. No serialisation, no read-modify-write, no key sprawl. And when a client connects, the whole board ships as a single base64 string that decodes straight into a Uint8Array on the front end — the exact memory layout the renderer already wants. Snapshot delivery is one message and a few kilobytes, so a viewer whose phone dropped Wi-Fi in a tunnel is fully caught up the instant they reconnect.

Pixel ownership (who placed what) lives in a parallel Redis hash keyed by the same byte offset, so attribution rides along without bloating the hot path.

Batching, because stream delay is real

The naive version broadcasts every pixel placement immediately. With a few hundred viewers tapping at once, that’s a firehose of tiny WebSocket frames for no benefit — the stream itself is already fifteen seconds behind live.

So the server queues pixel updates per streamer and flushes them on a 200 ms interval as a single pixels.batch message. The viewer’s own pixel paints instantly and optimistically on their device, so the app still feels immediate to the person tapping. Everyone else sees it a fifth of a second later, which nobody notices on a broadcast with fifteen seconds of delay.

Fan-out goes through Redis pub/sub rather than the server’s in-process socket map, which means the day I need a second backend instance, it already works.

Cooldowns the client cannot lie about

Every viewer gets a 60-second cooldown after placing a pixel. The client shows a countdown ring, but that timer is purely cosmetic. The truth is a single Redis operation:

SET cooldown:{streamerId}:{username} 1 PX 60000 NX

NX makes it race-free — if the key already exists, the write fails and the placement is rejected, no matter how many sockets the user opened or how fast they tapped. There’s a second guard at the transport layer capping any client at five messages per second regardless of type.

The abuse problem with anything like this is identity. IP-based limits break on CGNAT (everyone on the same mobile carrier looks like one person), and cookie-based limits die to an incognito tab. So ChatMural has no anonymous mode at all. You log in with Twitch or YouTube via NextAuth, and your cooldown key is your verified platform identity. Clearing cookies gets you nothing. Usernames are globally unique and first-come, first-served, permanently bound to the underlying platform account ID — so nobody can impersonate a well-known handle on someone else’s canvas.

That authentication has to cross a domain boundary, because Next.js and Fastify are deployed separately and browser cookies won’t follow. The handoff: Next.js mints a 60-second JWT containing the verified identity, signed with a secret shared only between the two services. The browser passes it once in an Authorization header, Fastify verifies the signature cryptographically, and from then on the identity is bound to the socket. Short expiry, server-to-server trust, no cross-domain cookie hacks.

Postgres remembers, Redis forgets

Redis holds the live canvas. Postgres, via Prisma, holds history — periodic CanvasSnapshot rows storing the base64 board plus ownership metadata, and a StreamerPixelOwner table for “who painted on this mural” analytics.

Two details worth stealing:

Hash before you write. Snapshotting a canvas nobody touched is a wasted round trip to Postgres. Each snapshot SHA-256s the board state plus its metadata and compares it against the last hash cached in Redis. Identical? Skip the write entirely. On a quiet channel the snapshot job costs one Redis GET.

Keep three, drop the rest. Every snapshot write prunes everything past the three most recent per streamer. Disaster recovery doesn’t need a hundred versions of the same mural, and the free-tier database plan really doesn’t need them either.

On startup the server pulls the latest snapshot, validates that the buffer length matches the expected canvas size, and rehydrates Redis from it. A backend restart mid-stream costs the audience nothing.

Streamers can also export any snapshot from their dashboard as a PNG or an SVG. The SVG export is a nice trick with a pixel canvas: one <rect> per non-empty cell means the mural scales to poster size with no blur at all.

Reconnection, because phones

Viewers are on mobile, walking around, switching networks, locking their screens. Dropped sockets are the normal case, not the error case.

The client reconnects with exponential backoff — min(1000 × 2^attempts, 30000) — capping at 30 seconds so a backend restart doesn’t get hammered by every viewer at once. Two close codes are excluded from retrying: 1000 (deliberate close) and 1008 (auth failure), because retrying a rejected token just burns battery. On every successful reconnect the server leads with a fresh full snapshot before resuming deltas, so there’s no way to accumulate drift from missed updates.

Where it runs

Local development is docker compose up — Redis 7 and Postgres 15 in Alpine containers, plus an optional Redis Commander container behind a debug profile for poking at the canvas buffer by hand.

In production the Next.js app deploys to Vercel and the WebSocket server runs as a Docker container on Render. That split isn’t aesthetic: serverless functions terminate long-lived connections, so anything holding a socket open for an hour needs a real long-running process. Configuration is entirely environment variables, injected at the platform level — nothing sensitive is in the repo.

There are paid power-ups too (splash bombs, region nukes, instant-pixel inventory that skips the cooldown). Checkout is handled entirely by a hosted payment provider with server-side webhook signature verification — no card data ever reaches my infrastructure, which is exactly how I want it.

What “weekend project” actually meant

It was a weekend for the first version: canvas in Redis, one hardcoded test streamer, pixels moving over a WebSocket. That part genuinely was two days, and it was the fun part.

Everything after that was the long tail nobody warns you about — OAuth for two platforms, a streamer dashboard, snapshot history, a moderation toolbar, the reference-image underlay, terms and privacy and refund pages, SVG export. The prototype is the weekend. The product is the three months of small decisions after it.

Still ahead: multi-instance scaling (the pub/sub layer is ready, the deploy isn’t), emote stickers with an asset moderation pipeline, and per-streamer moderation tools beyond the current nuke-and-erase.

It lives at chatmural.com. If you stream and want your chat to build something instead of just typing at you, it takes about sixty seconds to try.


Edit page
Share this post:

Next Post
The case that could breathe (homelab, part 3)