Link to this section Overview
Wordle is a popular word guessing game, kind of like Mastermind but for letters. I've written a few clones of the game in different languages.
Link to this section Motivation
The TypeScript clone was born out of a desire to understand the letter coloring procedure. I had played Wordle before, and I wanted to see if I could implement it myself.
The Rust clone was written because I wanted to learn more about Rust. I figured Wordle was a complex enough game that implementing it cleanly would require a decent understanding of Rust features and best practices.
Link to this section Technical Description
Link to this section TypeScript
Code is available at breqdev/wordle.
I built this project in React, but I wanted to ensure the game logic was sufficiently decoupled from the rendered result. I wrote this logic in two pure TypeScript functions, rowColoring
and keyboardColoring
.
The row coloring function assigns colors to each letter in the word. I took advantage of the type system to define letters in the target as explicitly nullable, allowing them to be "removed" when matched by a letter in the guess.
export function rowColoring(guess: string, target: string) {// Store the color alongside each guess letterlet guessLetters: LetterGuess[] = guess.split("").map((letter) => ({letter,color: "gray",}));// Store the target in an array of nullableslet targetLetters: (string | null)[] = [...target];// First pass: match green lettersguessLetters = guessLetters.map(({ letter, color }, index) => {// green letters are matched by the specific index in the targetif (letter === targetLetters[index]) {// remove matching green letters from the pool// so that they aren't also matched as yellowstargetLetters[index] = null;return { letter, color: "green" };} else {return { letter, color };}});// Second pass: greedily match yellow lettersguessLetters = guessLetters.map(({ letter, color }) => {if (color === "green") {// don't modify existing green lettersreturn { letter, color };}// yellow letters are matched by searching the entire target wordelse if (color === "gray" && targetLetters.includes(letter)) {// remove yellow letters once matched,// each letter only matches oncetargetLetters[targetLetters.indexOf(letter)] = null;return { letter, color: "yellow" };} else {return { letter, color };}});return guessLetters;}
In Wordle, the keyboard serves an important role: it shows how much information you have gotten about a letter based on your guesses. Dark gray signifies that the letter does not appear (it was not colored in a previous attempt), yellow signifies that it does appear (it was colored yellow in a previous attempt), and green signifies that you have correctly guessed the position at least once (it was colored green in a previous attempt). As the coloring of each letter relies on the coloring of previous attempts, the keyboard coloring function makes use of the row coloring function to color each of the guesses.
export function keyboardColoring(guesses: string[], target: string) {const letters: Record<string, LetterGuess> = {};for (const letter of "abcdefghijklmnopqrstuvwxyz") {letters[letter] = { letter, color: "gray" };}for (const guess of guesses) {const coloring = rowColoring(guess, target);for (const { letter, color } of coloring) {if (letters[letter].color === "gray" && color === "gray") {letters[letter].color = "black";}if (letters[letter].color === "gray" || color === "green") {letters[letter].color = color;}}}return letters;}
Link to this section Rust
Code is available at breqdev/rust_wordle.
I wanted to rely on as many zero-cost abstractions as possible. For storing each row and each word, instead of Vec
s allocated on the heap, I decided to use fixed-length arrays with type aliases:
type Word = [char; 5];#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]struct Square {color: Color,letter: char,}type Row = [Square; 5];
I used a Trait to implement printing the row:
trait PrintWordle {fn print_wordle(&self);}impl PrintWordle for Row {fn print_wordle(&self) {// ...for square in self.iter() {let mut boxed = "│ ".to_owned();boxed.push_str(&square.letter.to_string());boxed.push_str(" │");print_colored(&square.color, &boxed);print!(" ");}println!("");// ...}}// ...row.print_wordle();
I tried to make use of a functional style for the scoring algorithm, relying on iterators for most of the heavy lifting:
fn score_guess(target: &Word, guess: &Word) -> Row {// Map each letter of the target to an Option, so we can "remove" it laterlet mut remaining = target.map(|c| Some(c));// All tiles start off whitelet mut result = guess.map(|letter| Square {color: Color::White,letter,});// Use `.enumerate()` to check for the right tile in the right indexfor (i, square) in result.iter_mut().enumerate() {if target[i] == guess[i] {square.color = Color::Green;remaining[i] = None;}}// Greedily take remaining unmatched target letters to turn guess letters yellowfor (i, square) in result.iter_mut().enumerate() {if square.color == Color::White {if let Some(pos) = remaining.iter().position(|&c| c == Some(guess[i])) {square.color = Color::Yellow;remaining[pos] = None;}}}// Any unmatched squares become grayfor square in result.iter_mut() {if square.color == Color::White {square.color = Color::Gray;}}result}
I tried to keep this all straightforward, but I still wasn't too confident that I had nailed all of the edge cases. I was delighted by Rust's testing support:
#[cfg(test)]mod tests {use super::*;fn expect_score(target: &str, guess: &str, colors: Vec<Color>) {// ...}#[test]fn correct_guess() {expect_score("ARRAY", "ARRAY", vec![Color::Green; 5]);}// ...}
Using cargo
was also a welcome relief from fighting with C++ and git submodules. I used rand
to pick a random target word, colored
to print colored squares to the terminal, and serde_json
to read the wordlist files.
Link to this section Results
The TypeScript implementation works well, and it's actually my preferred Wordle to use due to its simple design, infinite puzzles, and the fact that it lets me keep playing after 6 wrong guesses. In hindsight, some memoization could have improved the performance of my declarative approach, as recoloring every row on every render undoubtedly has a performance penalty. That said, it would have been a tradeoff, and I don't think it's necessary given the relatively small number of guesses being used.
The Rust implementation is undoubtedly a bit less usable, being a CLI app, but I learned a lot about using constructs within Rust. While TypeScript had given me some intuition for how type aliases and type inference work, and C++ had given me a basic understanding of stack and heap memory, concepts such as Traits and the borrow checker were completely new to me. This wasn't a huge project, but the variety of data structures and paradigms it involved gave me a decent birds-eye view of Rust as a language.