Wordle in Remix: Part 5 - Actions

Wordle in Remix: Part 5 - Actions

·

8 min read

This is the fifth article in a series where we create a Wordle clone in Remix! 💿 We go step by step, from setting up the application to creating pages and implementing game logic. In this article, we will be adding server-side actions. We will also implement the game logic and allow the user to make guesses. Ready? Let's go!

Game logic

We will start by implementing a module with the core logic for our game. It will provide three functions.

  • A function that checks a guess against the reference word.
  • A function that checks if a game has been won.
  • A function that checks if a game has ended.

Let's create a new file, app/game.ts where we will implement the above.

Checking the guess

We will start with a function that checks a guess and marks each letter according to Wordle rules. It will accept two things - a guess and a reference word. We will start with converting both of these to arrays of characters.

// app/game.ts
export function checkGuess(guess: string, reference: string) {
  const word = reference.split("");
  const guessLetters = guess.split("");
}

Let's also add an empty result, marking each guess letter as a miss to start with. This is what we will return from the function.

// app/game.ts
import { ResolvedWordGuess } from "./types";

export function checkGuess(guess: string, reference: string) {
  const word = reference.split("");
  const guessLetters = guess.split("");
  const result: ResolvedWordGuess = guessLetters.map((letter: string) => ({
    letter,
    status: "miss",
  }));

  return result;
}

Now we need to correctly mark each letter. We will start with marking letters that are in the right place. One important thing we need to do is, once we mark a letter, to remove it from the word so that we won't later overwrite the mark.

guessLetters.forEach((letter, i) => {
  if (word[i] === letter) {
    result[i].status = "match";
    word[i] = "";
  }
});

Once we've got this, we can do the same for letters that are still marked as misses, and are included in the reference word but not in the right position.

guessLetters.forEach((letter, i) => {
  if (result[i].status === "miss" && word.includes(letter)) {
    result[i].status = "include";
    const index = word.findIndex((l: string) => l === letter);
    word[index] = "";
  }
});

The remaining letters are misses, so we have that already marked correctly. That's it! We can move to the other two functions.

Checking for win and loss

The first one will accept a result, similar to the one returned from the function we've just implemented. The user wins when each letter in the result is marked as a match.

export function isWin(result: ResolvedWordGuess) {
  return result.every(({ status }) => status === "match");
}

Finally, for a given set of guesses, the game ends when the user has previously tried 5 times, together with the current guess making up for 6 in total.

export function isEnd(previousGuesses: ResolvedWordGuess[]) {
  return previousGuesses.length === 5;
}

If you went with your own implementation, but want to make sure that it is correct, you can run this set of tests against it. 🧪

Wiring up the logic

We already have a form implemented on the /play page. As you might remember, when it's submitted, our application throws an error. 🙃 We're going to put the missing piece of implementation so that's no longer the case.

Remix allows you to handle form submissions with another part of Route Module API which is an action. You can define an action handler for a route, which is what we're going to do right now.

The function, similar to a loader, gives us access to the current request. Let's start with getting the session cookie - you can do that the same way as in a loader.

// app/routes/play.tsx
import { ActionFunction, redirect } from "remix";
...
export const action: ActionFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
};

Here's what we want our action function to do. 👇

  1. Read the guess submitted by the user.
  2. Check the guess against the reference word.
  3. Append the result to previous user guesses.
  4. Check if the user has won, and if so, show the winning pop-up modal.
  5. Check if the game has ended, and if so, show the loss pop-up modal.
  6. Otherwise, let the user continue playing.

With that in mind, we can jump into the implementation.

Reading the guess

When we look back into our InputForm where the guess input lives, we can see that the element has the following attributes.

<input
  ref={inputRef}
  type="text"
  name="word"
  value={input}
  onChange={...}
  onBlur={...}
  maxLength={5}
/>

What's interesting here is the name attribute. From the request in the action, we can read all the data the user submitted. The user's guess will be available under a key that corresponds with the name attribute of that input in the form, which is word.

// app/routes/play.tsx
...
export const action: ActionFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
  const formData = await request.formData();
  const { word } = Object.fromEntries(formData);
};

Checking the user's guess

With what we've implemented so far, checking the guess becomes really straightforward - just call the checkGuess function. The reference word to guess, as you might remember, is stored in the session cookie under the word key.

// app/routes/play.tsx
import { checkGuess } from "~/game";
...
export const action: ActionFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
  const formData = await request.formData();
  const { word } = Object.fromEntries(formData);

  const result = checkGuess(word as string, session.get("word"));
};

Saving the current guess

We need to save the result we just created. Again, we have a place for that in our session cookie - under the guesses key. Let's append the current guess to all previous guesses.

// app/routes/play.tsx
...
export const action: ActionFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
  const formData = await request.formData();
  const { word } = Object.fromEntries(formData);

  const result = checkGuess(word as string, session.get("word"));
  const previousGuesses = session.get("guesses");

  session.set("guesses", [...previousGuesses, result]);
};

Moving to the next step of the game

Will all of that we can decide what should happen next in our game. First, let's check if the user guessed the word correctly. If so, we want to show the appropriate pop-up modal.

Fortunately, we implemented the modal to show up on /play/win route, so what we need to do in that case is to redirect to this route - Remix gives us a simple helper function for that. Finally, because we've been reading and writing to the session cookie, we need to commit the changes by setting a new cookie.

// app/routes/play.tsx
import { redirect } from "remix";
import { isWin } from "~/game";
...
export const action: ActionFunction = async ({ request }) => {
  ...
  if (isWin(result)) {
    session.set("status", "win");
    return redirect("/play/win", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }
};

If the user hasn't won and that was their last try, that means they failed - let's cover that case.

// app/routes/play.tsx
import { redirect } from "remix";
import { isEnd } from "~/game";
...
export const action: ActionFunction = async ({ request }) => {
  ...
  if (isEnd(previousGuesses)) {
    session.set("status", "loss");
    return redirect("/play/loss", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }
};

Finally, if none of the above happened, as a fallback we just redirect the user back to where they were, allowing them to make another guess.

// app/routes/play.tsx
import { redirect } from "remix";
import { isEnd } from "~/game";
...
export const action: ActionFunction = async ({ request }) => {
  ...
  return redirect("/play", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
};

Displaying previous guesses

You might notice that if you now input a guess and press enter it goes away. Trust me, the session contains that information, as we saved it in the previous section. It's not lost. But what happens here is we reload the page, so any data that was in the input is cleared.

Right now the grid items we render are only the letters from the input. We also need to display all previous guesses before that. We already get guesses from the loader function, so we need to grab this out of the useLoaderData hook and put it in the correct place.

// app/routes/play.tsx
import type { ResolvedWordGuess } from "~/types";
...
export default function Play() {
  const data = useLoaderData<{
    guesses: ResolvedWordGuess[];
    status: GameStatus;
  }>();
  const [input, setInput] = useState("");
  const resolvedGuesses = data?.guesses?.flat() ?? [];

  const gridItems: LetterGuess[] = [
    ...resolvedGuesses,
    ...input.split("").map((letter) => ({ letter })),
    ...new Array(30 - input.length - resolvedGuesses.length)
      .fill("\xa0") // &nbsp;
      .map((letter) => ({ letter })),
  ];

  return (
    ...
  );
}

When you now try to input a word and submit it, the guess is resolved on the server and displayed in the grid. The status of each letter available on previous guesses maps nicely to Tile status, which then controls the color. Feel free to peek into the implementation, to see how that's done.

Game Board

If you guess correctly, you see the win modal, while if you submit 6 guesses and still don't guess, you land on the loss pop-up. Sweet! 🥳

Win Pop-up

Loss Pop-up

Note: we haven't implemented reset functionality yet, but for development purposes, you can still reset the game state by clearing cookies in your browser.

Conclusion

We've made terrific progress today, as we have the majority of the game logic implemented! 🚀 Here's a short summary of things we've gone through:

  • implemented game logic such as word verification, checking for win and loss
  • added form action handling with session updates and redirects
  • displayed previous user guesses in the grid

There are still plenty of things to add around validation and making sure the user doesn't see or do something that they shouldn't have access to. That's all coming! Next up, we will implement resetting the game. We will also look into how we can handle multiple actions in a single route. See you! 👋

If you liked the article or you have a question, feel free to reach out to me on Twitter‚ or add a comment below!

Further reading and references

Â