WebUI for wordle in Rust

Jesper BisgaardJesper Bisgaard
10 min read

Following up on the articles about building a wordle implementation in Rust I also wanted to understand how I could provide a web interface for the game using web assembly.

There are a few ways to compile rust to web assembly there are a few packages that can be used. I am going to use wasm-pack for my project, it seems to be the package that, at the time of writing this blog post, has the most traction and there are many tutorials and guides which describe how to use it.

First, we need to install the package. This can be done globally using the curl command:

curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

or it can be installed in the project using cargo:

cargo install wasm-pack

I installed it using cargo, you can use whichever method you prefer.

Now that we are ready to compile to wasm there are a few things we need to change in the project. First, we have to update the cargo.toml file to make sure it can compile to web assembly and interface with javascript. We have to update the package declaration with values that will be used in the package.json file. These values are authors, description, license, and repository. These values are not mandatory but it's good to have them in case you want to distribute your package to a larger audience. We also have to add the lib declaration and set the crate-type to "cdylib". Once we do this we also have to add a src/lib.rs file which is the library main file that will be used when building the webassembly package. Lastly I had to add getrandom = { version = "0.2", features = ["js"] } in order to be able to compile the project. You can find a description of this issue here.

[package]
name = "rust_wordle"
version = "0.1.0"
authors = ["Jesper Bisgaard <jesperjb@gmail.com>"]
description = "Wordle for Rust"
license = "MIT/Apache-2.0"
repository = "https://github.com/jespermb/wordle-rust"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
rand = "0.8.5"
colored = "2.0.0"
wasm-bindgen = "0.2"
getrandom = { version = "0.2", features = ["js"] }

You can find the commit with the changes here

Next, we will move all of the functions for the game to a separate file so we can use them in the new library file. I have named the new file game.rs but any name you might prefer would work.

The functions in the game.rs file need to be public so they can be used outside the module and we have to update the CharaterMap struct so the properties are also public.

use rand::seq::SliceRandom;
use std::collections::HashMap;
use std::io;

/// Generate a random word
///
/// # Examples
///
pub fn choose_word() -> String {
    let words: Vec<&str> = vec!["apple", "green", "brown", "elder"];
    let chosen_word: Option<&&str> = words.choose(&mut rand::thread_rng());
    return chosen_word.unwrap().to_string();
}

pub fn read_one() -> String {
    let mut word = String::new();
    io::stdin()
        .read_line(&mut word)
        .ok()
        .expect("Please enter a word");
    word
}

#[derive(PartialEq, Debug)]
pub enum CharState {
    Correct,
    Wrong,
    Exists,
}
#[derive(Debug)]
pub struct CharacterMap {
    pub character: char,
    pub value: CharState,
}

pub fn check_word_correct(game_word: &str, chosen_word: &str) -> Vec<CharacterMap> {
    let game_word_count = count_unique_characters(&game_word);
    let mut state: Vec<CharacterMap> = Vec::new();
    let mut current_word_count: HashMap<String, usize> = HashMap::new();

    for (i, c) in chosen_word.chars().enumerate() {
        let mut map = CharacterMap {
            character: c,
            value: CharState::Wrong,
        };
        if does_character_exist(c, game_word) && is_position_correct(c, i, game_word) {
            map.value = CharState::Correct;
            if current_word_count.contains_key(&String::from(c)) {
                current_word_count
                    .insert(String::from(c), current_word_count[&String::from(c)] + 1);
            } else {
                current_word_count.insert(String::from(c), 1 as usize);
            }
        }
        state.push(map);
    }

    for (i, c) in chosen_word.chars().enumerate() {
        let char = &String::from(c) as &str;
        if does_character_exist(c, game_word) {
            let chosen_char = game_word_count.get(&String::from(c)).unwrap();

            let mut current_char_count: &usize = &0;
            if current_word_count.contains_key(char) {
                current_char_count = current_word_count.get(char).unwrap();
            }
            let mut map = state.get_mut(i).unwrap();
            if !is_position_correct(c, i, game_word) && current_char_count < chosen_char {
                map.value = CharState::Exists;
                if current_word_count.contains_key(&String::from(c)) {
                    current_word_count
                        .insert(String::from(c), current_word_count[&String::from(c)] + 1);
                } else {
                    current_word_count.insert(String::from(c), 1 as usize);
                }
            }
        }
    }
    return state;
}

fn does_character_exist(char: char, word: &str) -> bool {
    return !word.find(char).is_none();
}

fn is_position_correct(char: char, index: usize, word: &str) -> bool {
    return char == word.chars().nth(index).unwrap();
}

fn count_unique_characters(word: &str) -> HashMap<String, usize> {
    let mut char_count: HashMap<String, usize> = HashMap::new();
    for char in word.chars() {
        if char_count.contains_key(&String::from(char)) {
            char_count.insert(String::from(char), char_count[&String::from(char)] + 1);
        } else {
            char_count.insert(String::from(char), 1);
        }
    }

    return char_count;
}

We will also move all the tests into the new file but they do not require any changes so I will let you explore them in the repo. We now have to update the main file to use the new game module. We do this by adding mod game; at the top of the main.rs file. and we then update the main function so it is using the functions in the game mod.

fn main() {
    const WORD_LENGTH: usize = 5;
    let word = game::choose_word();

    let mut input: String;
    let mut number_of_guesses = 0;
    println!("Please enter your first word");
    loop {
        input = game::read_one().trim().to_lowercase();
        number_of_guesses += 1;

        if input.chars().count() == WORD_LENGTH {
            let correct = game::check_word_correct(&word, &input);
            let mut is_correct = true;
            for char in correct {
                let character = String::from(char.character);
                if char.value == game::CharState::Correct {
                    print!("{}", character.green());
                }
                if char.value == game::CharState::Exists {
                    is_correct = false;
                    print!("{}", character.yellow());
                }
                if char.value == game::CharState::Wrong {
                    is_correct = false;
                    print!("{}", character.truecolor(109, 109, 109));
                }
            }
            println!("");
            if is_correct {
                println!("You win!");
                break;
            }
            if number_of_guesses == 6 {
                println!("You lose!");
                break;
            }
        } else {
            println!("Invalid word. Please enter a word that's 5 characters long.")
        }
    }
}

You can see the commit here.

We are now ready to continue implementing our HTML interface.

Compiling the package to web assembly is done using the wasm-pack build command:

wasm-pack build --target web

Target web tells wasm-pack to compile a package that can be imported as a ESM Module but the web assembly package has to be manually instantiated. You can see the different targets on the wasm-pack website

Before we can run the build command, we have to create our lib.rs file in the src folder to compile the library.

To begin with, we will create an alert function to see that the web assembly integration works.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

Let's also create an index.html file at the root of the project and instantiate the webpack in this file.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Rust wordle</title>
  </head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/rust_wordle.js";
      init().then(() => {
        greet("WebAssembly");
      });
    </script>
  </body>
</html>

In order to test it out, you have to run a local web server. This can be done by installing http-server.

npm install -g http-server
http-server

Lets get started on the interface.

We need to manage our state, and since we are doing a simple web assembly example we will use a bit of unsafe code. This is not meant to run in production and should be replaced with a sage solution for any production code.

static mut NUMBER_OF_GUESSES: i32 = 0;

fn add_guess() {
    unsafe { NUMBER_OF_GUESSES += 1 };
}

fn get_guess() -> i32 {
    unsafe { NUMBER_OF_GUESSES }
}

We create a static mutable variable that can keep our current number of guesses. This variable is accessible through a couple of getter and setter functions.

With the state management in place, we can create the initial function to launch the application.

#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> {
    // Use `web_sys`'s global `window` function to get a handle on the global
    // window object.
    let window = web_sys::window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");
    let body = document.body().expect("document should have a body");

    // Manufacture the element we're gonna append
    let val = document.create_element("p")?;
    val.set_text_content(Some("Hello from Rust!"));

    body.append_child(&val)?;

    Ok(())
}

Here we tell wasm_bindgen to run this function when the application starts and we then create a p tag with the text "Hello from Rust!". We then also need to modify our index.html file. we will remove the then() function from the script section since it is no longer needed.

<script type="module">
      import init, { run } from "./pkg/rust_wordle.js";
      init();
    </script>

We will also add some HTML to allow the user to input their guesses.

<input type="text" id="word" />
<button id="submit">Submit word</button>
<div id="word_result"></div>
<div id="guess_result"></div>

Now we can update the run function to respond to the user's input. First we will create a guess_result function to check if a guess is correct or not.

#[wasm_bindgen]
pub fn guess_result(word: &str, name: &str) -> String {
    const WORD_LENGTH: usize = 5;

    add_guess();

    if name.chars().count() == WORD_LENGTH {
        let correct = game::check_word_correct(&word, &name);
        let mut is_correct = true;
        for char in correct {
            if char.value == game::CharState::Exists {
                is_correct = false;
            }
            if char.value == game::CharState::Wrong {
                is_correct = false;
            }
        }
        if is_correct {
            return "You win!".into();
        }
        let guesses = get_guess();
        if guesses >= 6 {
            return "You lose!".into();
        }
        return "Try again!".into();
    }
    return "Invalid word. Please enter a word thats 5 characters long.".into();
}

In this function we use the game check_word_correct to see if the player made the correct guess and we return a string with the evaluation result. We also keep track of the number of guesses in this function. We will also create a second function to check the characters in the word like we did in the console program. Both functions will receive the guessed word and the word selected by the program.

#[wasm_bindgen]
pub fn check_word(word: &str, name: &str) -> String {
    const WORD_LENGTH: usize = 5;

    if name.chars().count() == WORD_LENGTH {
        let correct = game::check_word_correct(&word, &name);
        let mut results = String::new();
        for char in correct {
            let character = String::from(char.character);
            if char.value == game::CharState::Correct {
                results.push_str(
                    format!(
                        "<span class='character' style='color: green;'>{}</span>",
                        character
                    )
                    .as_str(),
                );
            }
            if char.value == game::CharState::Exists {
                results.push_str(
                    format!(
                        "<span class='character' style='color: yellow;'>{}</span>",
                        character
                    )
                    .as_str(),
                );
            }
            if char.value == game::CharState::Wrong {
                results.push_str(
                    format!(
                        "<span class='character' style='color: grey;'>{}</span>",
                        character
                    )
                    .as_str(),
                );
            }
        }
        return results;
    }
    return "".into();
}

This function returns HTML as a string which we can use to update the interface with the results.

Now we have to update the run function to fetch the input and to update the UI.

#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> {
    let word = game::choose_word().as_str().to_owned();
    let window = web_sys::window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");

    let input = document.get_element_by_id("word").unwrap();
    let button = document
        .get_element_by_id("submit")
        .expect("no submit button");
    let game_result = document.get_element_by_id("guess_result").unwrap();
    let word_result = document.get_element_by_id("word_result").unwrap();

    let cb = Closure::<dyn FnMut()>::new(move || {
        let input_element = input.clone();
        let input_value = input_element
            .dyn_ref::<web_sys::HtmlInputElement>()
            .unwrap();
        let input_value = input_value.value();
        let guess_result = guess_result(&word, input_value.as_str());
        game_result.set_text_content(Some(&guess_result));
        let match_result = check_word(&word, input_value.as_str());
        let mut previous_html = word_result.inner_html();
        previous_html.push_str("<br/>");
        previous_html.push_str(&match_result);
        word_result.set_inner_html(&previous_html);
    });
    button
        .dyn_ref::<HtmlElement>()
        .expect("submit button should be an HTML element")
        .set_onclick(Some(cb.as_ref().unchecked_ref()));
    cb.forget();
    Ok(())
}

When the run function starts we choose a word which we will keep for the remainder of the lifetime. Then we get the window and from this we fetch the document, and then we find the game elements using the web_sys module. Now we have to create a Closure and we will specify that is has a dynamic mutable context, and we then move the reference so we can manipulate the data passed to the closure. We then fetch the data in entered in the input field using a dynamic reference to the HtmlInputElement and we use unwrap to get the result or close the program if it fails.

With the input value we then send it to our two functions and update the UI with the results from them.

After the closure we attach it to the buttons onclick function so its called when the user wants to check an input. And we then use the forget function to release ownership without running the closures destruction. and we then end the function.

Now running the web server will allow you to play wordle.

The final code can be found here

0
Subscribe to my newsletter

Read articles from Jesper Bisgaard directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jesper Bisgaard
Jesper Bisgaard

Web developer with a passion for architecture and speed.