Projects — michaelslop.org
This site — FastAPI, Windows 98, and a shared drawing canvas
← back to projects
2026-08-08 · python, fastapi, websockets, obs, frontend

Built with Claude Code. See how these projects were built for what that means in practice.

This page is being served by the thing it describes. The site is a FastAPI application: a personal site, a journal, this projects section, and — the part that took real work — a live drawing canvas that stream viewers paint on and which composites onto the broadcast in real time.

The look

Windows 98, via a vendored copy of 98.css and a bundled bitmap font. Both are served locally rather than from a CDN, which for the font is not a preference but a requirement: it is a pixel font installed on exactly one computer, so without bundling it every remote visitor silently falls back to something else and the whole design collapses into "a website with a teal background".

Templates lean on the CSS framework's own classes — windows, title bars, window bodies, status bars — so most pages are structural markup with very little custom styling on top.

The shared drawing canvas

The idea: viewers draw on the stream. Subscribers, mods and I get a canvas overlaid on the video player; strokes appear on the broadcast within a frame or two, and everyone drawing sees each other live.

The architecture that finally worked:

viewer draws → WebSocket → server holds the authoritative stroke list
                             ↓ broadcast to every connected client
                    ┌────────┴────────┐
              other viewers      a transparent page
              (see it live)      loaded by the broadcast
                                 software as a browser source

The server owns the stroke list, capped, and broadcasts every stroke to all connected clients. New arrivals get a full canvas snapshot on connect. The broadcast software loads the same renderer as a transparent browser source, which means the stream and the viewers are running identical rendering code and cannot disagree about what a stroke looks like.

The route I tried first, and abandoned

The original plan drove an existing drawing plugin in the broadcast software over its websocket API. It never rendered a single pixel. Not "rendered wrong" — rendered nothing, while reporting success.

I ruled out, over hours: the source name resolved correctly (a deliberately bogus one produced a proper error), the layer was topmost and enabled, its transform was correct, field names matched the plugin exactly. I tried pixel coordinates and normalised coordinates, two different tool types, several alpha values, several colour packings, and priming the surface first.

The answer was in the plugin's source. Its draw handler fetches a texture, and if that fetch returns null it skips the drawing work — and returns success anyway. A null texture is a silent no-op with a {"success": true} on it.

The lesson, which cost the most and is worth the most: verify pixels, not status codes. "The API returned success" and "220 segments acknowledged in 15 milliseconds" were treated as evidence that drawing worked. They only ever proved the requests were accepted. For anything visual, screenshot the actual composed scene and look at the image.

Replacing the plugin with a browser source pointed at my own transparent page removed a dependency, made stream and viewers render identically, and took less time than the debugging had.

Other things the canvas taught me

Performance was an architecture problem, not a tuning problem. The first version awaited one round trip per line segment, under two nested locks, with a segment-rate cap that silently discarded most of a normal scribble. Batching a whole stroke into one round trip took it to 220 segments acknowledged in 15ms.

A dropped-frame bug that looked like a rendering bug. The client sent only points accumulated since the last flush, so the segment spanning two flushes was never drawn at all — dashed lines on the broadcast, perfectly smooth locally.

Stroke width was in the wrong coordinate space. Positions were normalised 0–1 but widths were raw screen pixels, so the same stroke came out roughly three times thicker on a small canvas than on the full-size overlay. You would draw a fat line and the stream would show a thin one. Widths are now in reference-frame pixels, scaled at paint time.

A canvas over an iframe cannot release pointer events on hover. The obvious fix for "the canvas covers the video player's controls" is to drop pointer events near the bottom edge — but once the canvas stops taking input, the pointer is over the iframe, and an iframe reports no mouse events to its parent, so nothing can ever re-arm it. The fix is two layers: one that renders full-frame and never takes input, one that takes input and stops short of the control bar.

Local strokes fade slightly after a couple of seconds. The video is delayed, so without the fade you see your own stroke twice — once live on your canvas, then again baked into the video a moment later. The overlay page must never fade, since that canvas is what gets captured. Erasers never fade either: partial-alpha erasing only partly removes what is underneath, so a fading eraser lets erased strokes bleed back in.

Nothing is lost on restart. Drawing on/off state, sessions and the canvas itself all persist to disk, debounced. All three used to be memory-only, so every restart signed everyone out, flipped drawing off mid-stream and threw away chat's artwork — which from a viewer's seat read as "it kicked me out". A forced kill skips the graceful shutdown path entirely, so session creation now triggers a save of its own rather than relying on one at exit.

A catch-all route swallows everything registered after it. A parameterised single-segment route silently 404'd a sibling endpoint defined below it. Register catch-alls last, or better, don't.

Cache-busting is not optional behind a CDN. Static assets are cached at the edge for hours, so a deploy left viewers on stale JavaScript. Asset URLs now carry a content hash, which means the browser requests a genuinely new URL whenever the file changes. Testing the bare URL directly will still show you the stale copy and look like a broken deploy — always test the versioned URL the page actually asks for.

A known limitation, stated plainly: the overlay hard-codes a 16:9 assumption and fails silently on a vertical canvas — strokes stretch to fill portrait with no error and no warning, landing somewhere other than where they were drawn. Vertical output needs its own overlay with real aspect handling. This is the main reason vertical streaming is deferred rather than attempted.

Auth and access

Sign-in is Twitch OAuth. Drawing is a subscriber perk, with moderators and me bypassing it; subscription status is checked at sign-in using the viewer's own token and cached on the session.

Two access bugs worth recording, both of which failed silently from the user's side:

Allowlists were compared without normalising case. A hand-added moderator name never matched, because the platform supplies logins lowercased. The mod simply got no access, with nothing on screen to explain why.

Session roles were frozen at sign-in. Adding someone to the moderator list did nothing for the entire lifetime of their existing session — they kept getting the permission-denied dialog with no indication that anything had changed. Roles are now re-resolved on every request.

Both belong to the same family: an authorisation failure that gives the user no information is indistinguishable, from their seat, from the feature being broken.

Operational reality

The site runs only while I am streaming. That is a deliberate decision rather than an outage: nothing brings it up automatically, including after a reboot.

Editing the Python requires restarting the service — there is no auto-reload in the running configuration. The HTML template for the control panel is read per request, so that one only needs a refresh. Content markdown is cached against file modification time, so saving a file is enough to make the change appear.

The most useful operational habit I picked up here: before concluding a deployed fix failed, compare the process start time against the file's modification time. A surprising share of "the fix didn't work" is "the fix isn't running yet".

CS student · developer · streamer

localhost:8000