I run a lot of remote meetings, and the first five minutes are usually silence and black camera tiles. So I built a set of small multiplayer games that run in the browser: Draw & Guess, Two Truths and a Lie, Emoji Charades, Zoom In and a few more. Everyone joins from one link, with no accounts.
The idea was simple. The bugs were more interesting, so here are three of them.
1. The game kept showing the answer
Zoom In shows a mystery picture as an extreme close-up, then slowly zooms out. Whoever names it first scores the most.
Playing it, I noticed that each round flashed the whole picture for a moment before zooming in. That ruins the game.
The cause was React doing its job. The picture element was reused from one round to the next. At the end of a round it was fully zoomed out, and a 0.25-second CSS transition animated it back up to full zoom when the next picture arrived. For a quarter of a second, you could see the new picture at its full size.
The fix is one prop. Key the element by puzzle, so each picture mounts fresh instead of animating from the old state:
<div className="zi-view" aria-hidden="true" key={c.id}>
If a CSS transition ever shows you something it shouldn't between two states, ask whether those should be the same element at all.
2. Nobody owned the clock
A party game has to keep moving on its own, and the obvious design has a flaw: if only the host's browser moves the game on and the host's Wi-Fi drops, the round just sits there.
The game state is a pure reducer over an action log. Time comes from action.at and randomness from action.nonce, so every client that replays the same log reaches the same state. That means any client can safely push the game forward. The problem becomes who should do it, and when.
The answer is a staggered timer. The host and the current drawer fire on time, and everyone else waits a few seconds:
const driver = isDrawer || me === room.ownerId
const late = driver ? 0 : 3500
const fire = (key, at, action) => {
if (now < at + late) return
const prev = fired.current[key]
if (prev && now - prev < 6000) return
fired.current[key] = now
act(action)
}
Normally the host or drawer fires first. If they vanish, everyone else steps in 3.5 seconds later. The room may send the same action more than once, so each action carries a turnId and the reducer ignores any that no longer apply.
3. My analytics had been wrong from day one
This one isn't about games, but it cost me more than the others.
I wrote a tiny cookie-free page counter, with the endpoint in a constant:
const URL = 'https://…'
Further down, the code called new URL(document.referrer) to read where a visitor came from. That line was now trying to construct a string. It threw, a catch returned an empty value, and every visit was recorded with no referrer. 217 page views later I noticed that not one of them had a source.
So I spent two days making decisions about traffic with no idea where it came from. Renaming the constant took ten seconds. The lesson I took is to test your measurement with a known input on the day you ship it, because a counter that goes up looks like a counter that works.
Try it, or tell me what's broken
The games are free with no sign-up, and they run on React, Vite, Vercel and Supabase realtime channels: fun-retro.com/games.
I'd especially like to hear about lag or sync glitches with five or more people in a room. How do you handle timers when any client can disappear?
I build these with AI as a pair-programming partner, and this post was drafted with AI assistance from the real code and bug history.
Top comments (0)