The opposite tradeoff

The AI never volunteering a profanity was a free filter. Filtering what a player types into their own initials is the same list applied to the opposite tradeoff.

Stopping the game from volunteering a profanity as its own move had been free: a flagged word just never gets offered, and stays fully playable if a real player types it. Leaderboard initials and PvP display names invert that tradeoff completely, since a false positive there blocks a real person’s actual input, maybe their actual initials, rather than costing an AI nothing. That inversion was reason enough to leave the question open the first time and grill it as its own decision once real design attention could go toward it.

The grilling session’s first finding reframed the whole problem before any filtering logic got written: leaderboard initials and PvP host and guest names turned out to already be the same field, not two systems needing two fixes. Every surface, both leaderboards and both sides of a PvP game, normalizes through the same one-to-three-character value. Filtering meant checking one field, once.

That length cap did something else useful too: since an entry longer than the field’s own maximum can never appear inside a string that short, only the blocklist’s shortest entries can ever match at all, which turned what might have been a repeat of the full-word substring check into exact matching against a small subset instead. The client gets its own copy of just that subset, kept outside $lib/server so the input components can validate live as a player types without pulling server code into the browser bundle, while the server stays the one gate that actually decides:

/**
 * Rejects with 422, not 400 — these routes throw plain 400 for several other,
 * unrelated reasons (missing fields, a stale token, a failed replay), and a
 * client needs to tell "your initials aren't acceptable" (client-actionable:
 * pick different characters) apart from those to show the right message.
 */
export function assertAcceptableInitials(raw: string, language: string): string {
	const initials = normalizeInitials(raw);
	if (initials.length === 0) {
		throw error(422, `playerName must contain 1-${MAX_INITIALS_LENGTH} letters (A-Z, Æ, Ø, Å)`);
	}
	if (isBlockedInitials(initials, language)) {
		throw error(422, 'playerName is not allowed');
	}
	return initials;
}

A match blocks and requires a different choice, uniformly across all three surfaces, with no override or appeal and no explanation beyond generic rejection copy, the same neutral tone the rest of the game already uses for a word that isn’t in the dictionary. Review caught one ordering bug before it shipped: the two leaderboard routes had validated initials before consuming their rate-limit budget, which would have let a blocked name get retried for free forever, fixed by consuming the budget first. A second issue surfaced after merging, once the change was actually live: a returning player’s cached initials from a previous visit were restored by setting the field directly, skipping the same live check a fresh keystroke would trigger, so a value that had since become blocked showed an enabled submit button with no warning until the server’s own rejection came back. Re-running the check the moment a cached value loads closed that gap, and a one-line addition to the privacy policy’s existing initials section now covers both surfaces this filter reaches.