How to make an online multiplayer game with JavaScript and Fauna

Several months ago, I decided to build an online multiplayer game. I have friends and family who live far away but we all love to play games together, so we are always on the lookout for games we can play online while we chat on Zoom.

After trying several online multiplayer games in the browser, I decided to try building my own. I have been writing JavaScript and React for many years, so I was confident that I could build a decent game UI.

But since my game needed to support multiple players, I also needed a database and an API that I could use to connect people to a game session.

There are many databases and API services out there, but one option really caught my eye:

Fauna 😍

What is Fauna?#

Fauna is a developer-friendly data API, so it stores your data and provides multiple ways to access your data.

If you love to build things but don’t like to deal with complicated database infrastructure, Fauna is an excellent choice.

Fauna is also one of the easiest ways to set up a GraphQL API and GraphQL has many benefits for all kinds of applications, both large and small. I chose to use GraphQL for my game application, but it’s up to you as you can use Fauna with or without GraphQL.

I was so smitten with Fauna’s ease of use and their GraphQL capability, I chose it for my first real-time game even before they supported real-time streaming (spoiler: real-time is supported now!).

Why Fauna for building multiplayer games?#

Easy to get started: Fauna’s billing model makes it pain-free to get started with any project, games included.

Zero operational overhead: Fauna is available instantly as a serverless utility and delivers limitless capacity. No need to worry about downtime or scaling if your game suddenly spikes in popularity.

Real-time streaming: Online multiplayer games need fast interactions and Fauna’s real-time capability is incredibly easy to implement.

Effortless concurrency: With online multiplayer games, you usually need to worry about multiple users trying to write to the same document or database table. Fauna’s optimistic calculations handle concurrency for you.

How online multiplayer browser games work#

Consider one of the most basic online multiplayer games you could build: Rock Paper Scissors.

In Rock Paper Scissors, 2 players simultaneously make a selection (rock, paper, or scissors). Then, both selections are revealed and a winner is declared, unless of course both players make the same choice and the result is a tie. To keep things interesting, our game will repeat this sequence until one player wins 3 times, also referred to as “Best of 3.”

If we were to capture this game sequence as a JSON object, it might look something like this:

{
  "selections": [
    [
      { "playerId": "1", "selection": "Rock" },
      { "playerId": "2", "selection": "Paper" }
    ],
    [
      { "playerId": "1", "selection": "Scissors" },
      { "playerId": "2", "selection": "Paper" }
    ],
    [
      { "playerId": "1", "selection": "Paper" },
      { "playerId": "2", "selection": "Rock" }
    ],
    [
      { "playerId": "1", "selection": "Rock" },
      { "playerId": "2", "selection": "Scissors" }
    ]
  ]
}

The game sequence itself is an array of rounds where each round is captured as a 2-item array that captures each player’s selection for that round.

We can also describe this document with GraphQL schema:

type GameSession {
  selections: [[PlayerSelection]]
}

enum SelectionType {
  Rock
  Paper
  Scissors
}

type PlayerSelection @embedded {
  playerId: ID!
  selection: SelectionType!
}

It’s okay if you aren’t familiar with GraphQL syntax. In a bit we will see how this schema definition allows us to query our Fauna database for the latest game state. If you want to dig further into GraphQL with Fauna you may want to check out Simple GraphQL with Fauna or Get started with Next.js + Fauna in 5 minutes.

This data structure is enough to capture our game state and store it in Fauna, but how do we allow multiple players to interact with this data from their browser?

How multiplayer games work

Both browsers can communicate with our Fauna database but they cannot communicate with each other directly. So in order for each player to know the current state of the game, the browser needs a way to know when data has been updated in the database.

But how can the browser know when the database is updated?

Before real-time streaming, long polling was the only option#

One way for the browser to know when the database is updated is to just periodically ask for the latest game state.

Polling

A GraphQL query to fetch the latest game state would look like this:

query GetGameSession($id: ID!) {
  findGameSessionById(id: $id) {
    id
    selections
  }
}

Using Apollo Client with React, we can execute the query like so:

// GET_GAME_SESSION is the query defined above
const { data, loading, error } = useQuery(GET_GAME_SESSION, { variables: { id: 'the_game_session_id' } });

By default, useQuery makes a single request. If I want to set that up for long polling, I just need to set a pollInterval to tell Apollo how frequently to poll.

// GET_GAME_SESSION is the query defined above
const { data, loading, error } = useQuery(GET_GAME_SESSION, { variables: { id: 'the_game_session_id' }, pollInterval: 2000 });

This is relatively easy to set up, especially if you are already familiar with Apollo Client, but there are a few drawbacks to long polling that you should be aware of.

First, long polling is only as fast as your poll interval. If you poll every 5 seconds, there could be a 4.99 second delay from when your opponent makes a selection to when you see an update in your UI.

Second, long polling is resource intensive as each request requires use of the network and your database. Fauna’s metered billing is cost effective, but you still want to use it wisely. If you try to minimize latency by keeping the poll interval shorter, you are then forcing the browser to execute more queries. Not only does this incur additional data transfer on the player’s browser but it also incurs load and possibly real dollar cost in your Fauna database.

Thankfully, Fauna has a better solution for you: real-time streaming.

Real-time streaming with Fauna#

Fauna now supports real-time data streaming, an ideal solution for online multiplayer games (and many other applications!).

For any document in your Fauna database, you can establish a connection and subscribe to events to know anytime the document is updated.

Real-time streaming

I like to think of streams as “data over time”. In the diagram below, each tick represents an event where each event provides new data about the state of the game. When a player makes a selection, a new event occurs and Fauna notifies both players of the updated game state.

Data over time

Earlier, we queried our game state through GraphQL so you might be wondering if Fauna supports GraphQL subscriptions, which are the GraphQL way to fetch real-time data. For now, Fauna does not support GraphQL subscriptions, but fear not! There are still options that allow you to leverage Fauna’s real-time streaming both with and without GraphQL.

Realtime data without GraphQL subscriptions#

Until Fauna supports GraphQL subscriptions, you have a few choices to fetch real-time data with Fauna.

Pure streams (no GraphQL)#

You may already be using GraphQL in other areas of your application. In this case you can still use GraphQL in those areas but use Fauna’s JavaScript SDK to stream data in only the areas where you need it.

import { Client, query as q } from 'faunadb';

const ref = q.Ref(q.Collection('GameSession'), 'the_game_session_id');

const subscription = client.stream
  .document(ref, { fields: ['document'] })
  .on('start', (data, event) => console.log(data))
  .on('snapshot', (data, event) => console.log(data))
  .on('version', (data, event) => console.log(data))
  .start();

In this example, we are subscribing to 2 events on a document in our Fauna database.

The start event provides a timestamp. Events that occur after this should always have a timestamp equal to or later than this event. The snapshot event provides the initial state of the document at the start of the stream. The version event provides details about changes any time the document is modified.

If we were to log out these events for a game of Rock Paper Scissors, it might look like this:

// From the "start" event
1615437123500000

// From the "snapshot" event
{
  ts: 1615437122453000,
  ref: Ref,
  data: {
    selections: [[]]
  }
}

// From the 1st "version" event
{
  document: {
    data: {
      selections: [
        {
          playerId: "292739620242719232",
          selection: "Rock"
        }
      ]
    }
  }
}

// From the 2nd "version" event
{
  document: {
    data: {
      selections: [
        {
          playerId: "292739620242719232",
          selection: "Rock"
        },
        {
          playerId: "292739632882254349",
          selection: "Paper"
        }
      ]
    }
  }
}

GraphQL refetch#

Consider the scenario where our Rock Paper Scissors game takes off like a rocket 🚀 and we want to allow players to create player accounts that allow them to make friends, track winning streaks, etc. Well, we would need to add another document type to capture these player accounts in our system.

To add player accounts, we could add this to our GraphQL schema:

type PlayerAccount {
  name: String!
  email: String!
}

With the addition of more document structures, it is even more helpful to leverage GraphQL’s aggregation capability, which allows us to fetch multiple documents in a single query.

query GetGameSession($id: ID!) {
  findGameSessionById(id: $id) {
    id
    selections
    playerAccounts {
      id
      name
      email
    }
  }
}

Now that we are again fetching data through GraphQL and we don’t want to go back to long polling, we can simply tell Apollo Client to run the query again anytime changes are made to the document.

First, we can set up a useQuery like so.

const { refetch } = useQuery(GET_GAME_SESSION, { skip: true, variables: { id: 'the_game_session_id' } });

We pass skip: true to tell Apollo to skip the initial query as we don’t want to fetch data until the stream starts (more on this in a moment). Also note we are no longer getting the data straight from this query and instead getting a function called refetch, which allows us to rerun the query whenever we need to.

Now, we can again initialize the stream, except when our application is notified of document changes, we call refetch to fetch the latest GraphQL.

const RockPaperScissors = () => {
  const [gameSession, setGameSession] = useState();
  const { refetch } = useQuery(GET_GAME_SESSION, { skip: true, variables: { id: 'the_game_session_id' } });

  const fetchData = async () => {
    try {
      const { data } = await refetch();
      setGameSession(data);
    } catch (err) {
      console.error(err);
    }
  };

  useEffect(() => {
    const ref = q.Ref(q.Collection('GameSession'), 'the_game_session_id');

    const subscription = client.stream
      .document(ref, { fields: ['document'] })
      .on('start', fetchData)
      .on('version', fetchData)
      .start();

    return () => {
      subscription.close();
    };
  }, []);

  return <div>UI goes here</div>;
};

Initialize with GraphQL + update with stream#

There are a few downsides to executing a GraphQL refetch every time your document is updated.

First, the refetch adds additional latency for the player. Below is a list of logged durations for the refetch. So even though we know new data is available, we still force the user to wait an additional 100-500ms to reflect changes to the UI. This may be okay for a simple game of Rock Paper Scissors, but other games might require more speed.

Second, additional queries also incur cost. If you are trying to minimize your costs, you want to avoid as many unnecessary API reads as you can.

To eliminate the need for the refetch, we can instead execute a single GraphQL query to fetch all of our aggregate data and then use streaming to continuously update our game state.

const RockPaperScissors = () => {
  const [gameSession, setGameSession] = useState();
  const { refetch } = useQuery(GET_GAME_SESSION, { skip: true, variables: { id: 'the_game_session_id' } });

  const fetchData = async () => {
    try {
      const { data } = await refetch();
      setGameSession(data);
    } catch (err) {
      console.error(err);
    }
  };

  // We added this to update data locally rather than refetch
  const handleVersion = (data) => {
    setGameSession((gs) =>
      Object.assign({}, gs, {
        selections: data.document.data.selections,
      }),
    );
  };

  useEffect(() => {
    const ref = q.Ref(q.Collection('GameSession'), 'the_game_session_id');

    const subscription = client.stream
      .document(ref, { fields: ['document'] })
      .on('start', fetchData)
      .on('version', handleVersion)
      .start();

    return () => {
      subscription.close();
    };
  }, []);

  return <div>UI goes here</div>;
};

This is a bit more code and state in your application, but it can be worth it for the faster experience and reduced costs.

Complex game logic: In the browser or the backend?#

While we’ve been using Fauna to store and access our game data, one thing we haven’t seen is any real game logic. In our Rock Paper Scissors game, there isn’t much logic but we do need a way to evaluate the selections from the two players and determine a winner.

In plain English:

One thing we need to decide is where this determination should happen and it really boils down to 2 choices:

While Fauna’s query language, FQL, is very powerful and efficient for accessing data, it can be cumbersome to write complex logic with it. It can be challenging to get game logic right and often requires lots of iteration and debugging. Debugging FQL is possible, but more difficult than debugging JavaScript running in a browser. With JavaScript, you can use dev tools to set breakpoints and console.log your way to victory. Most modern web frameworks like React also support near-instant feedback cycles, which can save you a significant amount of time.

On the other hand, pushing your game logic down to Fauna centralizes your logic to one place. If you wanted to support more than one client (like a native mobile app), then you may want to consider pushing as much logic to Fauna as possible. Another benefit to pushing your logic to Fauna is it makes it easier to store derived information like game winners for future use. If you wanted to understand how often Rock wins compared to other choices, it would be much easier to query if you also determined and stored win/loss information in your Fauna document.

In this case, I chose to write scoring logic as a JavaScript function in my application code like so:

function score(gameSession, currentPlayerId) {
  const scoredSelectionsList = gameSession.selections
    ?.filter((s) => s.length == 2)
    .reverse()
    .map((selections) => {
      const currentPlayerSelection = selections.find((s) => s.playerId === currentPlayerId).selection;
      const opponentSelection = selections.find((s) => s.playerId !== currentPlayerId).selection;

      const scoredSelections = {
        result: 'Tie',
        currentPlayer: currentPlayerSelection,
        opponent: opponentSelection,
      };

      if (currentPlayerSelection == 'Rock') {
        if (opponentSelection == 'Scissors') scoredSelections.result = 'Win';
        if (opponentSelection == 'Paper') scoredSelections.result = 'Loss';
      }

      if (currentPlayerSelection == 'Paper') {
        if (opponentSelection == 'Rock') scoredSelections.result = 'Win';
        if (opponentSelection == 'Scissors') scoredSelections.result = 'Loss';
      }

      if (currentPlayerSelection == 'Scissors') {
        if (opponentSelection == 'Paper') scoredSelections.result = 'Win';
        if (opponentSelection == 'Rock') scoredSelections.result = 'Loss';
      }

      return scoredSelections;
    });

  const currentPlayerScore = scoredSelectionsList.reduce((prev, curr) => {
    if (curr.result == 'Win') return prev + 1;
    return prev;
  }, 0);

  const opponentScore = scoredSelectionsList.reduce((prev, curr) => {
    if (curr.result == 'Loss') return prev + 1;
    return prev;
  }, 0);

  return {
    currentPlayer: currentPlayerScore,
    opponent: opponentScore,
    selections: scoredSelectionsList,
  };
}

It might help to look at a sample input and output for this function.

Sample scoring input:

// currentPlayerId:
"292824494445167112"

// gameSession:
{
  selections: [
    [
      { playerId: "292824494445167112", "Rock" },
      { playerId: "292824508034712077", "Paper" }
    ],
    [
      { playerId: "292824494445167112", "Rock" },
      { playerId: "292824508034712077", "Scissors" }
    ],
  ]
}

Sample scoring output:

// result
{
  currentPlayer: 1,
  opponent: 1,
  selections: [
    {
      currentPlayer: "Rock",
      opponent: "Scissors",
      result: "Win"
    },
    {
      currentPlayer: "Rock",
      opponent: "Paper",
      result: "Loss"
    },
  ]
}

Building multiplayer games has never been easier#

It takes just a few minutes to set up a Fauna database. From there, you don’t need to worry about scaling your infrastructure at all. Instead you can focus on the fun stuff: building the game experience you want for your players.

If you want to see a full implementation of online multiplayer rock paper scissors using the code and principles we covered here, check out Rock Paper Scissors.

Questions or comments? Find me on Twitter.

A newsletter for curious developers

Join me on the fantastic journey of software development. JavaScript, CSS, HTML, React, Next.js, Flutter, GraphQL, Fauna.