Keeping a daily word game's answers off the client with Cloudflare Workers and D1
I build Keiko's Words, a free daily word game: three secret words a day, each guessed from up to five one-word clues that start abstract and get easier with every miss. This post covers three implementation choices that
I build Keiko's Words, a free daily word game: three secret words a day, each guessed from up to five one-word clues that start abstract and get easier with every miss. This post covers three implementation choices that keep it fair: the answers never reach the browser, guesses are de-duplicated without storing them, and typos are forgiven without letting near-miss words win.
1. The browser never gets the answer
Many daily games ship the whole answer list in a JS bundle. Here the puzzle bank lives in a D1 table, and the Worker only returns what the player has earned: the clues revealed so far, and the answer once a word is finished.
export function publicProgress(record, content) {
const state = typeof record.state === 'string' ? JSON.parse(record.state) : record.state;
return { revision: record.revision, rounds: state.rounds.map((round, index) => ({
revealed: round.revealed, status: round.status, attempts: round.attempts,
clues: content[index].clues.slice(0, round.revealed),
...(round.status !== 'playing' ? { answer: content[index].answer } : {}),
})) };
}
Every guess goes to POST /api/games/:date/actions, and the server decides the outcome.
Guests don't need an account. Their progress is returned as a payload plus an HMAC signature, kept on the device and sent back with the next move. The server rejects anything whose signature, date or puzzle edition doesn't match, so a guest can't edit local storage to mark a word solved.
2. Duplicate guesses without storing guesses
Typing the same wrong word twice shouldn't cost a second clue. The obvious fix is to store every guess, but I didn't want raw guess text in the database. Instead each guess is reduced to a keyed fingerprint:
export async function fingerprint(secret, account, date, edition, word, guess) {
return hmac(secret, JSON.stringify([account, date, edition, word, normalize(guess)]));
}
The round stores only these fingerprints. Without the secret they can't be reversed or compared across players.
3. Forgive typos, not near-misses
Losing a clue to a fat-finger feels bad, but simple fuzzy matching lets different real words through. The rules:
- Exact answers and listed aliases always win.
- A guess that is itself a real dictionary word is never treated as a typo of something else.
- Otherwise allow a Damerau-Levenshtein distance (swaps count as one edit) that scales with length: 0 edits under 5 letters, 1 up to 8, 2 beyond.
export const typoAllowance = answer => answer.length < 5 ? 0 : answer.length < 9 ? 1 : 2;
export function isAcceptedGuess(guess, puzzle) {
if (puzzle.accepted.includes(guess)) return true;
if (REAL_WORDS.has(guess)) return false;
return puzzle.accepted.some(answer => withinTypoLimit(guess, answer));
}
A build step checks every answer and alias against the guard, and tests make sure none of a word's clues can win through fuzzy matching.
Bonus: one day means a calendar day
New words go live at midnight in America/Toronto. Deciding which days are free (today and the two before) compares calendar dates, not now - 48h, so 23- and 25-hour daylight-saving days don't shift the free window.
The whole thing is a single Worker plus D1 and plain JS with no framework. If you'd like to see the clue design in action, today's puzzle is free. I'd love feedback on whether the first clues feel fair.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.