Making Fast5 (#2): Backend Matters

Making Fast5 (#2): Backend Matters

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:


A big part of Convex's magic is enabling developers to manage distributed global state as a smooth extension of their app's existing local state: storing and retrieving data is done in JavaScript / TypeScript, queries can leverage the full power of JavaScript functions, and you can share code and typing information between the browser and the backend.

And yet, Convex by design remains quite explicit about the division between the code that runs in each place: your Convex functions are in a folder called convex/, and are pushed to your backend deployment using npx convex push.

If the goal is to be seamless as possible, and we're using the same tools and languages, you might ask: why does anything really need to run on the server anymore? And even if it does, do I, the developer, need to be so involved?

Yes! Even in a serverless world, the backend computing context being a real, discrete environment that you have control and influence over is not only necessary but hugely beneficial.

And while there are compelling benefits related to performance, asynchronous execution of background tasks, driving workflow—today, let's talk about trust. Trust is the particular backend benefit that Fast5 most needs.

The backend is the boss

Apps like Fast5 can utilize the fact that the backend can act as a trusted authority in an otherwise untrusted system. Why untrusted? Because we know anything that runs in the browser can be observed by and even manipulated by the user. Heck, as web developers, we poke at 3rd party apps in Chrome developer tools all the time!

On the other hand, the code and state that runs on the backend can only be observed and manipulated by the app author. Our user's browsers can't get to it, no matter how badly they might want to.

In the case of Fast5, this matters because both players are trying to guess the secret word:

Dunno what the word is, but it has "LO" and "C" in it!

We could still build Fast5 on a "dumb" database without real, trusted backend computing, but the game would be really vulnerable to cheating by impish web developers: without the trusted backend checking each guess for matching letters, the only entity left to do the job is the player's own browser! So we would have to make the secret word available to the web browsers since they're responsible for checking the next guess and advancing the game.

As you can imagine, after a little bit of time in the console finding the variable holding the "secret" word, and a little bit of scripting, the aforementioned impish web dev would be crushing all challengers with immediate correct guesses every round.

Secret-keeping server

Let's look at how Convex utilizes the distinct backend execution environment to have true secrecy and to inhibit cheating.

First, in each round, a secret word has to be picked! From the Convex mutation createRound:

  const word = WORDS[Math.floor(Math.random() * WORDS.length)];
  const round = {
    word: word,
    user1: {
      guesses: [],
      spying: false,
    },
    user2: {
      guesses: [],
      spying: false,
    },
    winner: null,
    overflow: false,
  };
  const id = await db.insert('rounds', round);
A new round is created with a random secret word and stored

In this Convex function, a random word is chosen from the WORDS word list, and then a new round is created. Of course, the word needs to be stored permanently in the backend at round creation time, otherwise, we'd have no way to consistently check for matches.

But what information is relayed to browsers as the round progresses? From queryRound:

return {
    word: round.winner ? round.word : null, // no winner, no word reveal
    user1: {
      guesses: guesses1,
      scores: scores1,
      spying: round.user1.spying,
    },
    user2: {
      guesses: guesses2,
      scores: scores2,
      spying: round.user2.spying,
    },
    winner: round.winner ?? null,
    overflow: round.overflow,
  };
The state of the round is returned from Convex to the browsers

As you can see, the word is only returned back to the browser if the round has a winner—if the round is over. Otherwise, the word field is null and the browsers have to keep guessing.

Secret for you, not for me

The target word is secret from both users until guessed by either. But some information is known by one user and secret from the other.

In Fast5, as in wordle, you have full information about which of your letters are hits and misses in the hidden word. However, unless you are choosing to "spy" on your opponent (and thus accepting the associated scoring penalty), you only get to see your opponent's matching tiles, not matching letters. Check it out:

All my info is on the left, but no letters on the right.

To accomplish this, first, we need to have an iron-clad way to identify who is calling our Convex functions. Fast5 uses Convex's integrated auth support with this helper function:

export const getUser = async (db, auth): Promise<User> => {
  const identity = await auth.getUserIdentity();
  if (!identity) {
    throw new Error(
      'Unauthenticated call to function requiring authentication'
    );
  }
  const user = await db
    .table('users')
    .filter((q: any) =>
      q.eq(q.field('tokenIdentifier'), identity.tokenIdentifier)
    )
    .unique();
  return user;
};

The user object returned from this function is guaranteed to be backed by a proper OAuth 2 identity provider.

Then, when we return all the guess-matching info in the round, we filter each user's hits and misses based on which user is asking for it:

  const user = await getUser(); // Use above for authenticated user 
  var { guesses, scores } = buildGuessState(
    round,
    round.user1,                 // Give us user1's guess matches
    round.user2.spying,          // Is user2 "spying" on user1?
    user._id.equals(game.user1), // Is the authenticated user user1?
    over
  );
The guess-hinting for a particular user in buildGuessState only includes information on specific letters if these are the user's own guesses, or if they are "spying" on the other user

Using these two methods, Convex supports giving each player in Fast5 a view of the game that hides secret information to ensure fair play.

What's next?

In the next blog post, we'll explore how Convex's support for true ACID transactions is used to ensure that only one player wins each round.