Building a Wordle helper with Vue.js 3.0

Building a Wordle helper with Vue.js 3.0

ยท

6 min read

Howdy, stranger ๐Ÿค . How you doin'? I'm in the process of learning Vue.js and as an easy project to get started with the main concepts I'll be building a Wordle helper. So... since you are here, you might as well stick around and take this as an introductory article to Vue or as a project idea if you will ๐Ÿ˜.

But first let's get...

The basic idea ๐ŸŒ 

How does this "Wordle helper" works? First you need to know what Wordle is... okay I'll give you 2 secs, go google it quick... here let me help you. Right! It would be very helpful to have a list of Wordle words because I haven't memorized the near 13000 words on the game. Also it would be VERY helpful to filter these words as needed based on the guesses we make in the game.

For example we could have this interface: Building a Wordle helper with Vue.js_2022-05-30 17.56.13.excalidraw.png

Please take a breath and be sure to not get your mind blown...

Getting our hands dirty

Okay we have everything we need to start coding fun stuff. Now let's create a simple Vue app:

npm init vue@latest

And after removing the files of the default Vue greeting, I've come up with this directory structure:

Pasted image 20220531160816.png

Hey but wait a second, what are those folders and files? We'll get there.

Showing the words

First we need the dataset with all the words from Wordle. After 3 intense seconds on the browser I found this repo. Let's define a function to fetch the words, also let's extract this function to it's own file just to remove some unncesessary logic from the main file and keep things simple.

// src/api/wordList.js

export async function retrieveWords() {
  let words_txt = await fetch(
    "https://raw.githubusercontent.com/tabatkins/wordle-list/main/words"
  ).then((res) => res.text());

  // This is useful because it will return an array of just words
  return words_txt.split("\n");
}

We need a way to show the list of possible words given the letters on the inputs. This should do the trick for now.

// src/App.vue

<script setup>
import { ref } from "vue";
import { retrieveWords } from "./api/wordList.js";

retrieveWords().then((data) => {
  total_words = m_words.value = data;
});

// I've kept the the main list with all the words on it's own variable.
// But I've defined another array which will contain the *filtered* words
// after inserting letters on the inputs.

let total_words = [];
const m_words = ref(null);

</script>

<div>
  <h3>Possible words: {{ m_words.length }}</h3>
  <ul>
    <li v-for="(word, index) in m_words" :key="index">{{ word }}</li>
  </ul>
</div>

Inputs

We need to add the inputs that we'll use to filter the list of words! We need to treat each letter on the input as an independent box because the order of the letters matter. So let's define the green, yellow and grey filters.

const word_filters = ref({
  green_letters: ["", "", "", "", ""],
  yellow_letters: ["", "", "", "", ""],
  grey_letters: "",
});

And... we need to render this on the template

  <div>
    <h3>Green letters:</h3>
    <input
      v-for="n in 5"
      :key="n"
      v-model.trim="word_filters.green_letters[n - 1]"
      maxlength="1"
      size="1"
      placeholder="_"
    />
  </div>
  <div>
    <h3>Yellow letters:</h3>
    <input
      v-for="n in 5"
      :key="n"
      v-model.trim="word_filters.yellow_letters[n - 1]"
      maxlength="1"
      size="1"
      placeholder="_"
    />
  </div>
  <div>
    <h3>Grey letters:</h3>
    <input v-model.trim="word_filters.grey_letters" />
  </div>

Filtering

Let's take a 5 minute break from Vue. Before we continue we need to define the pieces that will do the actual computation and filtering. We'll need the following:

  • filterMatching words, for the green letters on the correct position
  • filterContains words, for the yellow letters contained in the word but not in the position specified
  • filterExcludes words, for the grey letters that should not be in the word.

No need to think too hard... final result:

// src/lib/matcher.js

// Exporting the functions that we will use
export function filterMatching(words, letters) {
  const matching_words = words.filter((word) =>
    isMatchingWord(word, normalize_letters(letters))
  );
  return matching_words;
}

export function filterContains(words, letters) {
  const matching_words = words.filter((word) =>
    hasLetters(word, normalize_letters(letters))
  );
  return matching_words;
}

export function filterExcludes(words, letters) {
  const matching_words = words.filter((word) =>
    excludeWithLetters(word, normalize_letters(letters))
  );
  return matching_words;
}


// ==============Helper functions===================
function normalize_letters(letters) {
  return Array.from(letters).map((letter) => letter.toLowerCase());
}

function isMatchingWord(word, match) {
  for (let index = 0; index < 5; index++) {
    const match_letter = match[index];
    const word_letter = word[index];

    if (match_letter === "") {
      continue;
    }

    if (match_letter !== word_letter) {
      return false;
    }
  }

  return true;
}

function hasLetters(word, contains) {
  for (let i = 0; i < contains.length; i++) {
    const letter = contains[i];
    if (!word.includes(letter) || word[i] === letter) {
      return false;
    }
  }
  return true;
}

function excludeWithLetters(word, exclude) {
  for (let i = 0; i < exclude.length; i++) {
    const letter = exclude[i];
    if (word.includes(letter)) {
      return false;
    }
  }
  return true;
}

Connecting the dots

Now we need to put all of this together. Let's also define a function to fire the filters and another to reset the list with all the words

<script setup>
// ...

function handleReset() {
  m_words.value = total_words;
}

function handleSubmit() {
  let words = m_words.value;
  const filters = word_filters.value;
  words = filterExcludes(words, filters.grey_letters);
  words = filterContains(words, filters.yellow_letters);
  words = filterMatching(words, filters.green_letters);

  m_words.value = words;
}
</script>

And the template:

<template>
  <h1>Wordle helper!</h1>
  <!-- ... -->
  <div>
    <button @click="handleReset">Reset</button>
    <button @click="handleSubmit">Save</button>
  </div>

  <div>
    <h3>Possible words: {{ m_words.length }}</h3>
    <ul>
      <li v-for="(word, index) in m_words" :key="index">
        {{ word }}
      </li>
    </ul>
  </div>
</template>

Running the extra mile

There is a little flaw in our app. As you might have noticed the app lags a bit when loading the 30000 words, that's because we are rendering 30000 <li> items on the HTML when the page loads.

To fix this, we can opt for using a Virtual Scroller (luckly for us there is one for Vue 3.0) this will keep our page for rendering so many HTML tags and it will create a smooth and clean experience for our users.

Here is the final piece of script

<script setup>
// ...

// This is used by the template to put 3 words per row.
// These "rows" are generated dynamically on scroll by the RecycleScroller component
function sliceIntoChunks(arr, chunkSize) {
  const res = [];
  for (let i = 0; i < arr.length; i += chunkSize) {
    const chunk = arr.slice(i, i + chunkSize);
    res.push(chunk);
  }
  return res;
}
</script>

And template

<template>
  <h1 class="display-1">Wordle helper!</h1>

  <div v-if="m_words !== null" class="words-container">
    <h3 class="display-6">Possible words: {{ m_words.length }}</h3>
    <RecycleScroller
      class="scroller"
      :items="sliceIntoChunks(m_words, 3)"
      :item-size="45"
      :keyField="null"
      page-mode
      v-slot="{ item }"
    >
      <va-button
        class="word-option"
        :rounded="false"
        color="info"
        gradient
        v-for="(word, index) in item"
        :key="index"
      >
        {{ word }}
      </va-button>
    </RecycleScroller>
  </div>
  <div v-else class="loading-div">
    <va-progress-circle indeterminate />
  </div>
</template>

Also it would be nice to add some extra css to make things pretty and some quality of life changes to filter the words on each keypress. You can check the final result on my github repo!