We’ve all been there. It’s 11:30 PM, you have a deployment first thing in the morning, but you’re locked in a heated battle over a grid of letters. Word games have experienced a massive renaissance over the last few years, sparked by the viral explosion of Wordle and sustained by games like Connections and Strands. But as developers, we don't just play these games; we look at them and immediately start reverse-engineering the stack. How are they handling state? Is the validation happening on the client or the server? How do they handle high-concurrency daily resets without melting their database?
Today, a fascinating project caught my eye on Hacker News: One More Letter. It’s a beautifully simple yet addictive word-building game that challenges players to create words by adding, well, one more letter. While the gameplay is engaging, what really got my gears turning is the architectural blueprint behind these kinds of lightweight, highly interactive web games.
In this post, we’re going to dissect the engineering decisions behind building a modern, highly performant word game like One More Letter. We’ll look at efficient dictionary trie structures for instant validation, client-vs-server state sync, and how to build an infrastructure that can scale to millions of daily active users (DAUs) on a shoe-string budget.
The Core Challenge: Fast, Secure Word Validation
At the heart of any word game lies a deceptively complex problem: dictionary validation. Every time a user types a word, the system must instantly verify if it is a valid English word.
If you do this naively by sending an HTTP POST request to an API endpoint containing a database query (e.g., SELECT valid FROM dictionary WHERE word = 'developer') on every keystroke or word submission, your database will quickly cry for mercy under heavy load. Furthermore, network latency will make the game feel sluggish and unresponsive.
If you do this entirely on the client side by shipping a 270,000-word Scrabble dictionary (like SOWPODS) as a raw JSON array, your users will have to download a 3MB+ text file before they can even click "Play". That's a terrible user experience, especially on mobile devices.
Enter the Trie (Prefix Tree)
To solve this, we can use a Trie (pronounced "try") data structure. A Trie is an ordered tree data structure used to store a dynamic set or associative array where the keys are usually strings. Unlike a binary search tree, no node in the tree stores the key associated with that node; instead, its position in the tree defines the key with which it is associated.
By representing our dictionary as a Trie, we can compress our search space dramatically. Common prefixes share paths, which slashes the memory footprint. More importantly, we can serialize this Trie into a highly compressed format and load it into the client's memory, allowing for O(L) lookup times (where L is the length of the word) with zero network overhead during gameplay.
Here is how you can implement a basic, memory-efficient Trie in TypeScript for client-side validation:
class TrieNode {
children: { [key: string]: TrieNode } = {};
isEndOfWord: boolean = false;
}
class DictionaryTrie {
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
// Insert a word into the trie
insert(word: string): void {
let current = this.root;
for (const char of word.toLowerCase()) {
if (!current.children[char]) {
current.children[char] = new TrieNode();
}
current = current.children[char];
}
current.isEndOfWord = true;
}
// Search for a word in O(L) time
isValidWord(word: string): boolean {
let current = this.root;
for (const char of word.toLowerCase()) {
if (!current.children[char]) {
return false;
}
current = current.children[char];
}
return current.isEndOfWord;
}
}
Optimizing the Payload: Gzip and Bitwise Tries
While the runtime Trie is fast, shipping the raw Trie structure as JSON still takes up considerable bandwidth. To optimize this for production, you can compile your word list into a directed acyclic word graph (DAWG), which merges common suffixes as well as prefixes.
Alternatively, you can pre-process the word list into a single, highly-compressed binary string or a nested array structure, compress it using Gzip/Brotli on your CDN (Content Delivery Network), and decompress it on the client side during the app's initial hydration phase. A compressed 200,000-word dictionary can easily be shrunk down to under 300KB using these techniques!
Architecting the State: Local-First with Server Sync
When you're building a modern web game, you want the gameplay to feel instantaneous. When a user places a tile or submits a word, there should be no spinner. This requires a local-first architecture.
We want to store the game state in the browser's localStorage or IndexedDB so that if the user accidentally refreshes their browser, closes the tab, or loses internet connection while on the subway, their progress is preserved.
The Local State Loop
Using a React-like state management approach or a simple state machine, we can manage the game loop entirely in memory and persist it to disk reactively:
interface GameState {
score: number;
currentWordList: string[];
currentRound: number;
lastPlayedTimestamp: number;
}
const STORAGE_KEY = 'one_more_letter_state';
class GameStateManager {
private state: GameState;
constructor() {
this.state = this.loadState();
}
private loadState(): GameState {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error("Failed to parse game state, resetting", e);
}
}
return this.getInitialState();
}
private getInitialState(): GameState {
return {
score: 0,
currentWordList: [],
currentRound: 1,
lastPlayedTimestamp: Date.now()
};
}
public updateState(updater: (draft: GameState) => void): void {
updater(this.state);
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
this.syncWithServer(this.state);
}
private async syncWithServer(state: GameState): Promise<void> {
// Debounced network call to sync progress / update leaderboards
}
}
Preventing Cheating Without Spoiling the Fun
By keeping the validation and state local, we open ourselves up to a classic client-side gaming vulnerability: cheating. A user could easily open the DevTools console, inspect the memory, or modify the local storage state to give themselves a million points.
Does this matter? For a casual single-player game, maybe not. But if you have a global daily leaderboard, a single script-kiddie submitting a score of 2,147,483,647 will ruin the fun for everyone.
The solution is lazy server-side verification. Instead of validating every move in real-time on the server (which kills performance), you let the user play locally. When they complete the game, they submit their entire game session log (a history of every move, timestamp, and word played) to the server. The server then replays the game log in a fraction of a millisecond to verify that the score is mathematically possible and mathematically correct before adding them to the leaderboard.
Scaling to Millions: Serverless and Edge Deployments
If your game goes viral, you could go from 10 players to 500,000 players overnight. If you are running a traditional Node.js/Express backend on a single VPS, your server will crash, your database will lock up, and your viral moment will turn into a post-mortem write-up.
To build a virtually infinite-scale backend for a game like One More Letter, you should leverage **Serverless Computing** and **Edge Networks**.
The Edge Stack
- Static Assets (Frontend): Deploy your HTML, JS, CSS, and compressed dictionary files to a global CDN like Cloudflare Pages, Vercel, or Netlify. These are cached globally at the edge, meaning zero server load and lightning-fast load times for users anywhere in the world.
- Leaderboards and API: Use Edge Workers (like Cloudflare Workers or Vercel Edge Functions). These run code globally in V8 isolates close to your users, boasting sub-millisecond cold start times.
- Database/Cache: Use a globally distributed, low-latency database or key-value store. Cloudflare KV, Upstash Redis, or DynamoDB with global tables are perfect for saving daily high scores.
For example, saving a high score via a Cloudflare Worker using Upstash Redis looks like this:
import { Redis } from '@upstash/redis/cloudflare'
export default {
async fetch(request: Request, env: any): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const redis = Redis.fromEnv(env);
const { username, score, gameSessionId, moves } = await request.json();
// 1. Run server-side validation on 'moves' to prevent cheating
const isValid = validateGameSession(moves);
if (!isValid) {
return new Response('Invalid Game Session Detected', { status: 400 });
}
// 2. Save to global leaderboard sorted set
const dailyKey = `leaderboard:${new Date().toISOString().split('T')[0]}`;
await redis.zadd(dailyKey, { score: score, member: username });
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' }
});
}
}
function validateGameSession(moves: any[]): boolean {
// Implement your replay logic here
return true;
}
Wrapping Up
Building a successful indie web game like One More Letter isn't just about designing a fun mechanic; it's about executing a flawless user experience. By leveraging efficient client-side data structures like Tries, adopting a local-first state paradigm, and deploying to modern Edge infrastructure, you can build a game that load-tests itself, handles viral traffic surges with ease, and costs almost nothing to run.
What’s your favorite stack for building lightweight web applications? Have you tried experimenting with Trie compression or Edge database solutions? Let me know in the comments below!
Happy hacking!