In January I wrote about reviving bettertyping.org with AI coding agents. The project didn’t stop there. This post covers what changed since then, and takes a closer look at one part I find interesting: the bots that race you when nobody else is online.
What’s new
More ways to practice. Besides typing a quote on the homepage, there is now a timed test (30 seconds, 1 or 2 minutes, optionally with punctuation and numbers) and a daily challenge where everyone types the same text and gets ranked against each other. Your profile shows a heatmap of the letters you miss most, and after a few races BetterTyping builds practice texts from your weakest keys.
Reasons to come back. There are daily streaks, with freezes for the days you can’t make it, and optional reminder emails. You can set a WPM goal and see your progress towards it. All of this works without an account. When you sign in later, your streak comes with you.
Sharing. Every result has its own page with a preview image. You can challenge a friend to beat you on the same text, and there is a WPM badge for your GitHub README.
Signing in without a password. Next to GitHub, Google and Facebook you can now get a sign-in link by email.
Keyboards. You can tell BetterTyping which keyboard you are typing on. Each result remembers it, so you can compare your speed across keyboards.
Under the hood. The project runs on Elixir 1.20 and OTP 29. The biggest change was the multiplayer part: every lobby is now its own process. More on that below. The server also no longer trusts the speed the browser reports. It recomputes it from the text and the time and rejects results that don’t fit the time it measured itself.
How the bots work
Multiplayer typing has a chicken-and-egg problem. A race is only fun with someone to race against, and on a small site there often is nobody. So if you join a race and nobody else shows up, a bot joins you.

When a bot joins
When you click “Find online race”, you land in a public lobby. If you are alone, the lobby schedules a bot for 15 seconds later. If a real player joins in the meantime, the timer is cancelled and you race each other instead. As soon as two players are in the lobby, the countdown starts.
How fast it types
A bot that is much faster or much slower than you is boring. So the bot takes your average speed over your last 100 races as its target. Without any races yet, it types at 40 WPM, which is about the average typing speed.
It doesn’t type at a perfectly constant pace, though. The delay between two characters is derived from the target speed and varies by up to 15% in both directions:
defp schedule_next_character(state) do
# 1 word = 5 characters, so one character takes 60_000 / (WPM * 5) ms
base_delay = 12_000 / state.target_wpm
variance = base_delay * @wpm_variance
delay = base_delay + (:rand.uniform() * 2 - 1) * variance
Process.send_after(self(), :type_next_character, round(max(delay, 10)))
end
A bot is just another player
Each bot is a small GenServer. It subscribes to the updates of its lobby, just like the LiveView of a human player. When the race starts, it sends one character at a time to the lobby, through the same path a human player’s progress takes. The lobby doesn’t need to know much about bots: it marks them as such in the UI and counts a bot as finished once it has typed the whole text.
Three bugs on the way
The bot was the hardest feature of the project so far. Three bugs stand out.
Two bots in one lobby. In the first version, all lobbies lived in one shared Agent, and a separate scheduler process fired the bot timers. Each timer checked “is there no bot yet?” and then added one. When two timers fired at almost the same time, both checks passed before either bot had joined. The first fix was to claim the timer atomically. With one process per lobby, this kind of bug is gone for good: the timer is a message to the lobby process itself, and the lobby handles its messages one at a time.
Bots that came back. After a race the bots stopped, and then they were back. GenServers default to restart: :permanent, which means the supervisor restarts them even when they stop normally. Bots are meant to live for exactly one race, so they are now restart: :temporary. They also monitor their lobby and stop when it goes away.
A deadlock. In the new design, the lobby process starts the bot itself. The bot used to join the lobby in its init/1 callback, which calls the lobby. But the lobby was still waiting for init/1 to return, because starting a child blocks until it is initialized. Both processes waited for each other until the call timed out. The fix is to finish init/1 right away and join in handle_continue/2:
def init(%{lobby_name: lobby_name} = args) do
state = new_state(args)
# The lobby process may be the one starting this bot,
# so calling it from init/1 would deadlock.
{:ok, state, {:continue, :join_lobby}}
end
def handle_continue(:join_lobby, state) do
LobbyState.subscribe(state.lobby_name)
# join the lobby and monitor it
...
end
What bots can’t do yet
Bots never make mistakes, and they don’t slow down on hard words the way people do. The next step I’m thinking about is ghost races: racing against a recording of a real run, your own best one or the fastest of the day. That would make the opponent behave like a human, because it was one.
Still built with AI agents
As in January, AI coding agents wrote most of the code. BetterTyping has become my test bed for these tools: over the year I kept switching between Codex, Claude Code, Cursor and Amp to see how each of them handles a real, existing codebase. My part is deciding what to build, reviewing the result and trying it out. The lobby rewrite above is a good example: I described what was wrong with the old design, and the agent rebuilt it and wrote the tests.
Try it
Everything is free and works without an account: bettertyping.org. If something feels off, or you have an idea for the bots, I’d like to hear about it.