Building the Profile Page (And the Bugs I Found Doing It)

March 5, 2026 · 4 min read
By Made Me The Dev

Spent the last few days building out the profile page for TypeVelocity. It took longer than I expected — not because the design was complex, but because I kept running into things that were harder to wire up than they looked. This is a quick writeup of what I built, what broke, and what I'm still not totally happy with.

What the Profile Page Does

The goal was simple: give players a persistent stats dashboard. No account, no server, everything lives in localStorage. You play, your stats accumulate, you open the profile and see a real picture of how you play.

TypeVelocity profile page showing rank badge, stats, vibe check messages, and mode tables
The profile page — rank badge, per-mode stats, and the Vibe Check section

It ended up with five sections: a summary card with your best rank, a Vibe Check (more on that below), per-mode stat tables for Words / Sentences / Song broken out by difficulty, a Difficulty DNA bar showing which difficulty you actually play, and a badge grid for earned achievements.

The localStorage Problem

The first real headache was the data format. When I added per-difficulty tracking to the game, the highScores key in localStorage changed shape. The old format was flat — one best WPM per mode. The new format is nested — one slot per mode per difficulty.

Players who had played before the update still had the old format saved. So when the profile page tries to read their data, it would just silently show zeros everywhere. That's bad. I had to write a migration function that detects which format is present and normalises it before rendering anything.

// Detect format by checking for the new nested structure
var isNew = raw.words && raw.words.baby !== undefined;

if (isNew) {
    // merge per-difficulty slots into the display structure
} else {
    // old flat format — map into the standard slot only
    if (raw.words && raw.words.wpm) {
        hs.words.standard.wpm = raw.words.wpm || 0;
    }
}

Not elegant. But it works, and old data doesn't just disappear.

Vibe Check

This was the most fun part to write. Instead of just showing raw numbers, the profile generates 3–5 contextual messages based on how you actually play. Play only Baby mode with 30 games? It notices. Hit 65+ WPM? Different message. Never tried Sentences mode? It calls you a coward.

The hook line at the bottom is always your distance to the next rank. "You're 8 WPM from Speed Demon. One good run." That kind of thing. I like it. Gives people a reason to go back and play.

The Bugs I Found After Writing It

A few things broke that I didn't catch until later.

The missing null check

The username modal has a cancel button and a save button. I added a null guard on the edit button — but not on cancel. So if the element wasn't in the DOM for any reason, it would throw a TypeError on page load and kill the whole username flow.

// edit button had a guard:
if (editBtn) {
    editBtn.addEventListener('click', openModal);
}

// cancel button did not:
cancelBtn.addEventListener('click', closeModal); // throws if null

Fixed now. But it slipped through because the HTML always has the element, so it never threw in testing. Classic.

The accuracy message order bug

The Vibe Check has messages for different accuracy tiers. I had the >= 99% check above the >= 98% AND high WPM check. Which meant anyone with 99%+ accuracy and fast speed only ever saw the first message, never the combined one.

// wrong order — 99% players always hit this first:
if (bestAcc >= 99) {
    lines.push('99%+ accuracy. your keyboard has never been more respected.');
} else if (bestAcc >= 98 && bestWpm >= 60) {
    // this was unreachable for 99%+ players
    lines.push('high accuracy AND speed. genuinely annoying. stop it.');
}

The fix is just reordering the conditions. But I had to actually read the logic carefully to spot it — at a glance it looks fine.

Redundant variable declarations

Inside the table rendering function, I have both a song-mode branch and a words/sentences branch. Both branches declare var streak and var games. In JavaScript with var, that's technically legal — both declarations get hoisted to the same function scope and the second one is just ignored. It works. But if you ever convert to let, it breaks immediately.

Not a bug right now. Just a mess waiting to become one.

What's Still Not Great

The badge threshold for Streak Hunter is > 50 — strictly greater than. But the vibe check message says "50+ streak." Someone who hits exactly 50 gets the vibe message but not the badge. Minor, but the inconsistency is annoying once you notice it.

Song mode stats are tracked per-difficulty in the display but lifetime stats don't break down by difficulty — only by total games played. That'll matter more as the game gets more players. It's a known gap.

Actively Developing

The profile page is live now at /profile. All your stats from previous sessions are already there if you've been playing. No setup, no account — it just reads what's in your browser.

There's more I want to add over time. Per-session history would be useful. Maybe a shareable card. For now, the core stuff works and the Vibe Check is genuinely fun to read.

Go check your profile — your stats are waiting.

View Profile