Adding real-time duels to a math app

Math Hero's practice mode was fine. Students would open the app, do a few problems, close it. Day 7 retention hovered around 30%. Fine isn't good enough when you're trying to grow an app.

I'd been watching session data and noticed something: students who happened to be online at the same time and could see each other on the leaderboard practiced about 40% longer. Competition was doing something that gamification badges weren't. So I built duels -- two students go head to head solving the same problems in real time.

Day 7 retention jumped to 44% for students who played at least one duel. Enough to keep building.

Matchmaking when you don't have millions of users

This is the part nobody talks about in "how to build multiplayer" blog posts. They assume you have a massive player pool. I don't. On a good day, maybe a few hundred students are online at once.

My matchmaking works in tiers. When a student taps "Find opponent," the system first looks for someone within a tight skill band -- same grade level, similar accuracy on recent problems. If nobody's there after three seconds, the window widens. After six seconds it widens again. At ten seconds, if there's still no match, the student gets paired with a bot calibrated to their level. The bot doesn't announce itself as a bot; it just plays at a realistic pace with realistic mistakes. Most students can't tell, and the ones who can don't seem to mind.

The widening window is what made it work. A static matching threshold meant too many students staring at a "Searching..." screen and giving up.

Firebase Realtime Database as the sync layer

Both players need to see the same problems and watch each other's progress update live. I'm using Firebase Realtime Database for this -- not Firestore, because the latency characteristics are better for this use case. Each duel is a document with the problem set, both players' answer arrays, and timestamps. When one player submits an answer, the other sees the update in roughly 100ms. Fast enough to feel live.

The race condition

Two players submit an answer to the same problem within milliseconds of each other. Both clients optimistically show "You answered first!" because locally, each player's answer arrived before the other's. Classic distributed systems problem.

Fix was server timestamps. Every answer submission gets stamped by Firebase's server clock, not the client clock. Client shows an optimistic result but reconciles against the server timestamp within a second or two. Occasionally a student sees their "first!" flip to "second," but it's rare enough that nobody's complained.

Handling disconnects

Phones lose signal. Kids close the app mid-duel. Someone's mom calls them for dinner.

When a player disconnects, Firebase's onDisconnect handler fires and writes a status change to the duel document. Remaining player sees "Your opponent disconnected" and gets the win after a 15-second grace period. If the disconnected player comes back within that window, the duel resumes. State is all in Firebase, so the client just rehydrates from whatever's there.

The 15-second grace period was originally 30 seconds. Watching playtest sessions, 30 seconds of waiting felt like an eternity, and most kids who disconnected weren't coming back anyway.

Showing opponent progress without creating anxiety

Each player sees a small progress bar at the top showing how far their opponent has gotten. No details, just a bar. Early prototypes showed the opponent's current problem number and whether they got each answer right or wrong. Way too distracting. Students kept watching their opponent's progress instead of focusing on their own problems.

The minimal bar works because it creates just enough awareness. You know if you're ahead or behind, but you can't obsess over individual answers.

The result I didn't expect

I assumed winners would be the retained users. They're not. Students who lost their first duel came back at a higher rate than students who won. My theory is that losing creates a specific, achievable goal -- beat that score next time -- in a way that winning doesn't. A win is satisfying but closed. A loss is an open loop.

Still collecting data on this, and the sample size is small enough that it might not hold up. But it's changed how I think about the feature. I'd been worried about protecting students from losing. Turns out losing might be the more valuable experience, at least for retention.


-- Daniel