Wordle in Remix: Part 3 - Loaders and Session

Wordle in Remix: Part 3 - Loaders and Session

This is the third 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 cover storing the state of the game and sharing pieces of it in the correct places. Let's go!

Game state

There are a few things that we need to keep track of for each game.

  • Word to guess - for each game, we need to draw a random word for the user to guess. This should not be shared with the user until they finish the game.
  • User's guesses - we need to store consecutive user's guesses, for each guess marking each letter based on Worlde rules.
  • Game status - information whether the user is still playing, has won or lost. This is not really a separate piece of information, since it can be derived from the user's guesses. For simplicity, we will store it independently.

Where to store the state

There are at least a few possible ways we could store the game's state in any web application. Let's explore them and see which one fits our use case best.

The first option would be to store the state in the browser. As we mentioned previously, we can't share the word to guess with the user, unless we want to ruin all the fun for them. Most typically storing data in the browser allows the user to read it (without using more complex solutions). For that reason, we won't use this option.

The second choice would be a database. That's perhaps the most typical way to store data and it would fix the problem we would have when storing state in the browser. But our version of Wordle doesn't have any limitations in regards to how many times a user can play. That means our case is arguably simpler, and we don't need as robust a solution.

The third option would be to store the state in a cookie. A cookie is a small piece of data, that can be attached to any request between server and client. We can create a cookie session for each game. We can also do it in a way that only the server is allowed to read and write to it. Finally, this option doesn't require any additional backend services such as a database. This gives us just enough for what we need, so we will use this option. 👍

Here's the full documentation on handling cookie sessions.

New types

Let's first define a few more types that we will soon need.

// app/types.ts
...
export type ResolvedLetterGuess = { letter: string; status: LetterStatus };
export type ResolvedWordGuess = ResolvedLetterGuess[];
export type GameStatus = "play" | "win" | "loss";

We already had LetterGuess defined before - ResolvedLetterState is similar, but we expect status to always be defined. ResolvedWordGuess will represent a single user's guess, that has been already verified, with each letter marked according to Wordle rules. Finally, GameStatus represents the status of the game.

Remix has a useful helper to create a cookie-based session - createCookieSessionStorage. We will use it to create the game session.

Let's create an app/sessions.ts file. Due to Module Constraints we place it outside of the routes directory specifically to make the session available in all routes.

// app/sessions.ts
import { createCookieSessionStorage } from "remix";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      httpOnly: true,
      sameSite: "lax",
      path: "/play",
      secrets: ["s3cret1"],
      secure: true,
    },
  });

export { getSession, commitSession, destroySession };

Few important things to notice here.

  • Our session cookie is marked as httpOnly meaning that only HTTP API can read its contents. That means JavaScipt clients (browser) are not able to read this cookie directly. That's what we need to protect our word.
  • This cookie is available only at /play URLs - we don't need to have the cookie available on the homepage, only in the game.
  • Added secrets make the cookie signed and allow for automatic verification of its contents when it is received.
  • The cookie is marked as secure which means it only can be sent over HTTPS protocol.

In a nutshell, all of the above guarantee that the cookie is secure and can only be read and written to on the server, which is exactly what we need from our game state storage. 🥳

Finally, Remix gives us a couple of functions to handle the session.

  • getSession returns a session cookie. We will use this function to read the current state of the game.
  • commitSession allows setting a new cookie. We will use this function when updating the state of the game.
  • destroySession allows removing existing cookie. This one will be useful when we want to restart the game and clear the state.

The last two methods can be used to provide Set-Cookie header for the responses from the server.

Creating game state

When the user redirects to /play we should create a new game for them if there's no session already. For that, we will use the loader function from Remix, which is a part of Route Module API. This function get's called each time we enter a given route.

First, we need access to the current session. Loaders give us access to request to which we expect the cookie to be attached.

// app/routes/play.tsx
...
import { getSession } from "~/sessions";

export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
};

Next, let's check if the state already exists in the cookie, and if not, let's set the initial values. Here we use browser Cookie API from Remix.

// app/routes/play.tsx
...
import { getSession } from "~/sessions";
import { getRandomWord } from "~/words";

export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));

  if (!session.has("word")) {
    session.set("word", getRandomWord());
  }
  if (!session.has("guesses")) {
    session.set("guesses", []);
  }
  if (!session.has("status")) {
    session.set("status", "play");
  }
};

For simplicity, we will add app/words.ts module, which exports getRandomWord function. It will draws a random word for us. Here is the implementation of that module, it features a simple, hardcoded list of 5-letter words.

You can add more words or replace that with a more robust implementation fetching the words from some external dictionary API.

We will also return guesses and status from the loader function, it will come in handy later on. For that purpose, Remix gives a utility function that allows creating a JSON response. Finally, since our loader function changes the session, we have to update it explicitly by calling commitSession.

// app/routes/play.tsx
...
import { getSession, commitSession } from "~/sessions";
import { getRandomWord } from "~/words";

export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
  ...
  return json(
    {
      guesses: session.get("guesses"),
      status: session.get("status"),
    },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
};

Exposing the word to guess

Now that we have actually drawn a word, we can replace the temporary word we've put in our win and loss pop-ups. Let's go to win.tsx and create a loader for that route that will return the word from the session cookie.

// app/routes/play/win.tsx
...
import { json, LoaderFunction, useNavigate } from "remix";
import { commitSession, getSession } from "~/sessions";

export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));

  return json(
    {
      word: session.get("word"),
    },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
};

To access that data in our view, Remix gives us a special hook called useLoaderData - let's use it!

// app/routes/play/win.tsx
...
import { json, LoaderFunction, useLoaderData, useNavigate } from "remix";
...
export default function Win() {
  const { word } = useLoaderData<{ word: string }>();
  const navigate = useNavigate();
  const onClose = useCallback(() => navigate("/play"), []);

  return (
    <Dialog onClose={onClose}>
      ...
    </Dialog>
  );
}

With that, you should see a new, random word when you navigate to /play/win.

Win Pop-up

We need to repeat the same updates for /play/loss page. Once we have this, navigating to that page we should see the same word as on the success modal.

Loss Pop-up

Conclusion

That's it! We've created the game session and exposed parts of it in our views. 🥳 Here's a short summary of things we've gone through:

  • discussed what data we need to store
  • explored state storage options
  • created session cookie storage
  • initialized the state and used loaders to expose the data

In the next one, we will be focusing on game board implementation. 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