I built Keystrike because I kept using Monkeytype solo and wanted to actually race someone live. The idea is simple: you and a friend join a room, the same text appears for both of you, and whoever finishes first wins. WPM and accuracy tracked in real time.
The repo is at github.com/zraisan/keystrike if you want to look at the code.
The real-time sync problem
The first tricky part was keeping both players in sync without making one player’s view feel laggy. My initial approach was to emit a typing event on every single keypress and broadcast the other player’s progress. This worked, but at high WPM it created a noticeable flood of socket messages.
The fix was to throttle the broadcast: instead of emitting on each keystroke, I track the player’s current word index and emit only when that number changes. A player can backspace and retype within a word as much as they want, and nothing gets sent until they move to the next word. Fewer events, smoother experience.
socket.on("word_complete", ({ roomId, wordIndex, wpm }) => {
socket.to(roomId).emit("opponent_progress", { wordIndex, wpm });
});
WPM calculation
WPM is traditionally (characters typed / 5) / minutes. The divide-by-5 normalises for word length since a “word” is defined as 5 characters in the standard formula.
I calculate it on a rolling 10-second window rather than from race start. This gives a more accurate current speed instead of an average that gets dragged down by slow starts.
function calculateWPM(chars: number, elapsedSeconds: number): number {
if (elapsedSeconds === 0) return 0;
return Math.round((chars / 5) / (elapsedSeconds / 60));
}
Accuracy is just (correct chars / total chars typed) * 100, tracked separately from the WPM calc so mistyped and corrected characters still count against accuracy.
Room lifecycle
Each room goes through three states: waiting, countdown, and racing. The host creates a room and gets back a code. Their friend joins with that code. Once both players are connected, the server starts a 3-second countdown then emits race_start to both sockets simultaneously.
The tricky edge case was handling disconnections mid-race. If one player disconnects, the other should see a notification rather than being stuck waiting forever. Socket.io’s disconnect event handles this cleanly:
socket.on("disconnect", () => {
const room = getRoomBySocket(socket.id);
if (room && room.state === "racing") {
socket.to(room.id).emit("opponent_disconnected");
cleanupRoom(room.id);
}
});
What I would do differently
The room state lives entirely in server memory. That means a server restart wipes all active rooms. For a real deployment this would need Redis or at minimum a persistent store for room state.
I also hardcoded the word list as a static array. A better approach would be pulling from a curated dataset and categorising by difficulty, which is what Monkeytype does.
Fun little project. Took about two weekends.