Making Fast5 (#1): Local and Remote State

While making Fast5, Convex's wordle-style multiplayer racing game, we ran into several interesting challenges. This week, let's dig into managing the combination of local and global state.

Making Fast5 (#1): Local and Remote State

Recap: In Mid May 2022, Convex released Fast5, a multiplayer Wordle-style racing game as a demonstration of the Convex global state management platform. Throughout May and June, we'll be featuring articles diving into how we approached various engineering challenges while creating Fast5.

Previous posts:

Like all React apps, Fast5 contains components backed by state. When they're rendered, they look something like this:

A typical game of Fast5

Specifically, in the above game view, there is a Board, which consists of two BoardSides, each representing a particular player's guesses. Each side has six BoardRows, and those rows contain five BoardCell components. Simple enough.

Because Fast5 is a multiplayer game, this component state is mostly based on the shared view of the game managed by the Convex backend deployment—what Convex calls global state. Both players see the game progressing, so the backend is the authority on the full set of guesses each player has submitted and any matching letters within those guesses.

But some of the board component state is also purely local. Consider what happens when you start to type your next guess:

Perhaps the player thinks the word is "think"?

In this case, a new guess is being queued up.  But because it's not yet submitted to Convex, it's not yet part of the shared game state. The partial guess "thi" is only local state, not yet global state.

So the Fast5 app needs to use both local and global state for these components. One approach is to create a lot of logic within the BoardSide component to base each BoardRow's rendering on local or remote state, depending on how many words have been submitted to Convex. But combining state logic and view logic in this manner often gets messy and fragile to refactor.

How do we cleanly merge this relationship in the model between local and global state? We need some way to manage and merge state before the component gets involved. Fast5 uses Recoil for this, an excellent new state management library for React apps. In a nutshell, Recoil lets your app create atoms, which represent fundamental pieces of state, and selectors, which are derived states built by combining other atoms and selectors. If you'd like a primer on these ideas before exploring how Fast5 uses them, Recoil's "Core Concepts" doc is excellent.

Think global

The global state is managed by our Convex backend and shared by both players. It's updated whenever either player submits a new guess. Let's take care of it first.

First, let's create a Recoil atom that represents the global state of the current round:

export const backendRoundState: RecoilState<null | BackendRound> = atom({
  key: 'backendRoundState',
  default: null as null | BackendRound,

Next, we'll bind a Convex query function to that Recoil atom:

// Get the state setter for the backendRoundState
const [, setBackendRound] = useRecoilState(backendRoundState);

// The bound query for the current game reactively updated by Convex
const roundQuery = useQuery('queryRound', Id.fromString(gameId));

// Update the recoil atom whenever the global state changes
useEffect(() => {
    setBackendRound(roundQuery as BackendRound);
  }, [roundQuery, setBackendRound]);

And... that's it! Convex makes sure that any time the global game state changes because either player submits a new guess to the backend, the Recoil atom will automatically be updated. Then Recoil ensures this new atom value also updates any dependent selectors and React components.

Act local

Our local state isn't based on anything in a backend. Instead, it changes when the local player in the current browser starts to guess a word – basically, as they type letters (and backspace) on the keyboard, the board cells in the next row should populate with the letters from the corresponding word fragment.

So let's have a second atom that represents the set of letters the user has in their queued-up word:

export const currentLetters: RecoilState<string[]> = atom({
  key: 'currentLetters',
  default: [] as string[],

This atom is updated on KeyEvents. Specifically, an alphabetical keypress pushes a value onto the end of this array of letters, and a backspace pops one letter off. I've omitted that code here, but it does about what you expect.

Bring it all together

Finally, we can use a Recoil selector to merge the new letters with the currently shared global state:

export const boardState: RecoilValueReadOnly<null | BoardState> = selector({
  key: 'boardState',
  get: ({ get }) => {
    // Grab each of the above atoms.
    const backendRound = get(backendRoundState);
    const letters = get(currentLetters);
    // Create the grid with grey/yellow/green status markers
    // for each *global* row based on backendRound, and white,
    // pending status markers for *local* letters
    // that haven't been submitted yet.
    const board = mergeBoard(backendRound, letters);
    return board;

Now we simply base ourBoard component on this boardState selector, and viola! Anytime local or global state is updated, the board reactively redraws. As we develop and evolve our game, the component doesn't need to be constantly updated to revisit merging logic.

The whole local/global flow of state looks roughly like this:

Seeing it in action

To make it easier to visualize this flow, here's a short clip of a slightly modified Fast5 app that displays the states of these atoms and selectors as the game is played:

Notice that cells get tagged with status codes that the component turns into CSS styling. The backend status codes get passed through, populated by the Convex backend's knowledge of which letters were matches; the local letters are tagged with "pending" status codes by the boardState selector when they're merged in to the appropriate row.

What's next

That's it for local vs. global state. If you have any questions about state management or want to discuss any other Convex topic further, jump into our community! We're always excited to hear feedback and to talk about these topics.

In the next post, we'll talk about how Fast5 utilizes the secure Convex backend computing environment to hide each player's guesses from the other, and to keep the secret word... secret!