Tutorials

How to build a map-based guessing game using React and MapBox

Introduction

This tutorial will explain how to build a version of the Boston public transit mbtaguessr.com app which I developed. In this game, users have to guess the location of an MBTA stop chosen at random on a map without streets or transit lines. The scoring and game structure is similar to GeoGuessr: each guess can earn up to 5,000 points and each game consists of five guesses.

We’re going build this game using JavaScript and React. Some understanding of JavaScript and HTML is recommended for this tutorial, but no React knowledge is necessary.

Let’s get started

First, let’s make sure the necessary technologies are installed:

  1. Install Node.js using the download from their website. This lets us run JavaScript code from our terminal.
  2. This will also install a command-line program called npm, or Node Package Manager. This allows us to install other code packages in our app.

A (very basic) React app

With node installed, we can set-up a tool called create-react-app which will make the app creation much easier. To install, open your Terminal app and run:

npm install --global create-react-app

Next, we can get the project set up by entering:

create-react-app guessr-game

This will create a project with the name “guessr-game” stored in a folder of that name. Navigate to that folder by running cd guessr-game. All other commands in this tutorial should be run from inside that folder.

The create-react-app command will initialize our project with a lot of useful starter files. You can run the starter app by entering npm run start. Point your browser to http://localhost:3000. At this point, our app should look like this:

If you open src/App.js, you’ll see the file powering most of this starter app.

import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

If you are familiar with JavaScript and HTML, you’ll notice that this file seems to be mixing the two languages together. This is a feature of React, which allows us to define dynamic HTML components with complex logic in JavaScript.

This starter file comes with a lot of default styles and images that we don’t actually need in our app. So, to get started, let’s replace the contents of that file with:

import "./App.css";

function App() {
  return (
    <div>
      <h1>We have a blank canvas!</h1>
    </div>
  );
}

export default App;

Take a look at the complete code here.

If you reload http://localhost:3000, you should see a blank page with the “We have a blank canvas” message.

Installing Mapbox

The game will be powered by the Mapbox API, a rich, interactive map that we can build the game on top of. To get started, install mapbox-gl and react-map-gl using npm.

npm install --save mapbox-gl react-map-gl

There are two differences between this command and the one we ran earlier:

  1. We don’t add the “–global” option, because we only want to install these packages within our project
  2. We add the “–save” option, which tells npm that we want to record the versions of the packages so that we reinstall the same versions

Now, we need to set-up a Mapbox access token which will allow us to link our Mapbox account to the project. You should be able to find yours on account.mapbox.com after signing up for a free account.

Copy the whole token (including the “pk.” prefix) and store that in file at “.env.local” within the project root directory like so:

REACT_APP_MAPBOX_ACCESS_TOKEN=pk.YOUR_TOKEN_HERE

This file will automatically set an environment variable which will allow our Mapbox code to detect your account.

Now, we can replace the code in src/App.js to load a basic map like so:

import "./App.css";

import "mapbox-gl/dist/mapbox-gl.css";


import { Map } from "react-map-gl";

const initialViewState = {
  longitude: -71.0593,
  latitude: 42.35,
  zoom: 10,
};

function GameplayMap() {
  return (
    <Map
      style={{ width: 600, height: 400 }}
      mapStyle="mapbox://styles/mapbox/streets-v9"
      initialViewState={initialViewState}
      maxZoom={12}
      minZoom={8.5}
    />
  );
}

function App() {
  return (
    <div>
      <GameplayMap />
    </div>
  );
}

export default App;

This code introduces a handful of React concepts, so it’s worth breaking down exactly what’s going on here.

import { Map } from "react-map-gl";

This imports the Map component from the react-map-gl package. React components are essentially functions that return HTML elements. For example, a function like this:

function SimpleComponent() {
    return <h1>Hello</h1>
}

defines a component called SimpleComponent which just renders “Hello” as an <h1> element You could then render that component within another component like:

function NestedComponent() {
    return <SimpleComponent />
}

Components can also receive properties, which are similar to arguments to functions. For example:

function HelloComponent(props) {
    // The curly braces around props.name tell the code
    // that the contents should be evaluated like JavaScript code
    // instead of rendering the literal content "Hello, {props.name}"
   return <h1>Hello, {props.name}</h1>
}

will render “Hello, John” if it is passed the name property with the value John, which you would do like <HelloComponent name="John"/>

Now, back to the actual game code using this information:

function GameplayMap() {
  return (
    <Map
      style={{ width: 600, height: 400 }}
      mapStyle="mapbox://styles/mapbox/streets-v9"
      initialViewState={initialViewState}
      maxZoom={12}
      minZoom={8.5}
    />
  );
}

Our component, GameplayMap, simply renders the map component given to us by react-map-gl. We pass in some properties to the map component which tell Mapbox how to render and style the map.

Lastly, we add this code to the app component so it renders in the browser.

function App() {
  return (
    <div>
      <GameplayMap />
    </div>
  );
}

Adding interactivity

As of now our game is totally static — nothing changes no matter how the user interacts with it. It’s time to set-up some basic interactive states.

To do this, we will make use of React state. The React “state” for a component stores all of the bits of data that can change as a user interacts with it. This differs from the properties that we’ve used up to this point, which are pieces of data that change outside of the component. In our app, one of the key pieces of state is the guess — where the user clicks on the map. When we receive a guess, we want to take note of where it is so we can change the logic in our game in response.

DON’T MISS  Getting Started With R in RStudio Notebooks

To do this, we can use useState function from react:

import { useState } from "react";

// ...

function GameplayMap() {
    const [guess, setGuess] = useState(null);
    // ...
}

// ...

Take a look at the complete code here.

Here, we set-up a guess state variable with default of null, because the user has not made a guess when the app initially loads. Within GameplayMap, we can now set the guess variable to whatever value we need using the setGuess function created by useState.

Now, we just need to actually use and set the guess in the code:

// ...
import { Map, Marker } from "react-map-gl";

// ...

function GameplayMap() {
  const [guess, setGuess] = useState(null);

  let guessMarker = null;
  let guessMessage = null;
  if (guess !== null) {
    guessMarker = (
      <Marker longitude={guess.longitude} latitude={guess.latitude} />
    );
    guessMessage = (
      <p>
        Current guess is at longitude={guess.longitude} latitude=
        {guess.latitude}
      </p>
    );
  }

  return (
    <div>
      <Map
        style={{ width: 800, height: 800 }}
        mapStyle="mapbox://styles/mapbox/streets-v9"
        initialViewState={initialViewState}
        maxZoom={12}
        minZoom={8.5}
        onClick={(eventData) => {
          setGuess({
            longitude: eventData.lngLat.lng,
            latitude: eventData.lngLat.lat,
          });
        }}
      >
        {guessMarker}
      </Map>

      {guessMessage}
    </div>
  );
}

Take a look at the complete code here.

In the above code, we made a handful of changes:

  1. We take advantage of the onClick property of the map component to run a custom function that saves the user’s guess by calling setGuess. The format of the eventData variable is determined by react-map-gl and documented here. We can use their documentation to figure out how to get the data we need. In this case, we need the lng and lat, or longitude and latitude, from the lngLat field
  2. We set a guess marker and guess message on the screen when the guess variable is not null
  3. We’ve modified the width/height of the map for easier testing

Lastly, open up the src/App.css file and make it totally empty. This file had some default styling which will start to interfere with our map now. When everything is configured, the app should respond when you click on the map and look something like this:

Though the app is clearly not a complete game yet, we’ve essentially covered all of the React/JavaScript features we will need to use. Now, we just need to string those together to get a functioning game.

Building a basic game

Obviously, the full game will require a dataset with all of the stations in the MBTA. Though we’ll import this later, let’s get started with just a single station to make testing very easy. Create a new file at src/data.js with this content:

const allStations = [
  {
    lines: ["orange", "greenD", "greenE"],
    longitude: -71.05829,
    latitude: 42.363021,
    name: "Haymarket",
  },
];

export default allStations;

Now, we can import these stations in our app just like we were importing the Mapbox/React code:

import allStations from "./data";

Now, for the game to work, we want to add a few more bits of functionality:

  1. The game should cycle through a random selection of stations (though right now it will always be Haymarket)
  2. The game should allow a user to confirm their guess before it’s finalized
  3. The game should give the user a score from 0-5000 for each guess, similar to the GeoGuessr game that our game is styled after.

Selecting a station

For the first task, we can easily solve this by installing lodash, a popular JavaScript library with some useful utility functions: npm install --save lodash

Then, add the following:

import _ from "lodash";

// ...

function GameplayMap() {
    const station = _.sample(allStations);
    //...
    return (
        <div>
            <h1>{station.name}</h1>
            {/* ... */}
        </div>
    )
}

Take a look at the complete code here.

The lodash library is commonly imported using the “_” alias, which may be a tad confusing to see at first. Here, we use the sample function to randomly sample from a list and save that in the station variable which we can use later for calculating scores.

Confirming guesses

Whether or not a guess is “confirmed” is another piece of React state, just like the guess itself. To get the user to confirm a guess, we just want them to click a button to finalize their selection. So, we can add this feature like so:

function GameplayMap() {
  const [isGuessConfirmed, setIsGuessConfirmed] = useState(false);
  //...

  let confirmGuessButton = null;
  let stationMarker = null;
  if (isGuessConfirmed) {
    // only show the station once the user confirms their guess
    stationMarker = (
      <Marker
        longitude={station.longitude}
        latitude={station.latitude}
        color="red"
      />
    );
  } else {
    confirmGuessButton = (
      <button
        onClick={() => {
          setIsGuessConfirmed(true);
        }}
      >
        Confirm
      </button>
    );
  }

  return (
    <div>
      <h1>{station.name}</h1>

      <Map
        style={{ width: 800, height: 800 }}
        mapStyle="mapbox://styles/mapbox/streets-v9"
        initialViewState={initialViewState}
        maxZoom={14}
        minZoom={8.5}
        onClick={(eventData) => {
          if (!isGuessConfirmed) {
            setGuess({
              longitude: eventData.lngLat.lng,
              latitude: eventData.lngLat.lat,
            });
          }
        }}
      >
        {guessMarker}
        {stationMarker}
      </Map>

      {confirmGuessButton}
    </div>
  );
}

Take a look at the complete code here.

Scoring guesses

Once we have a guess and show the station, we need to give the user a score based on how far the user’s guess is from the actual station. A perfect guess is worth 5,000 points; as you get further away, you lose points. The code below does that calculation — it’s not super important to understand all of it:

// ...

// calculate distance in meters using https://www.movable-type.co.uk/scripts/latlong.html
function distanceInMeters(a, b) {
  const lng1 = a.longitude;
  const lng2 = b.longitude;
  const lat1 = a.latitude;
  const lat2 = b.latitude;

  const a1 = (lat1 * Math.PI) / 180;
  const a2 = (lat2 * Math.PI) / 180;

  const deltaA1A2 = ((lat2 - lat1) * Math.PI) / 180;
  const deltaLambda = ((lng2 - lng1) * Math.PI) / 180;

  const res =
    Math.sin(deltaA1A2 / 2) ** 2 +
    Math.cos(a1) * Math.cos(a2) * Math.sin(deltaLambda / 2) ** 2;
  const c = 2 * Math.atan2(Math.sqrt(res), Math.sqrt(1 - res));
  return 6371e3 * c;
}

/**
 * Calculate the score of a guess with the maximum score of 5,000
 * Scoring uses a quadratic function to reward being very close. At 15,000 meters
 * away, the score becomes 0
 */
export function getScore(guess, actualLocation) {
  const distance = distanceInMeters(guess, actualLocation);

  return Math.round((5000 / 15000 ** 2) * Math.max(15000 - distance, 0) ** 2);
}

function GameplayMap() {
    // ...
    let scoreDisplay = null;
    if (isGuessConfirmed) {
        // ...
        scoreDisplay = <p>Score: {getScore(guess, station)}</p>
    } else {
        // ...
    }

    return (
       <div>
          {/* ... */}
          {scoreDisplay}
       </div>
    )
}

Take a look at the complete code here.

Putting this all together, we should now have a functioning single-guess version of the game that looks like this:

Polishing up the gameplay

Now that we have a single-guess version of the game, we just need to add a representation of a full game of five guesses.

DON’T MISS  Getting Started with tidyverse in R

To do this, we need to wrap our current component in a component that will keep track of an individual guess and another that will keep track of every guess made over the course of a game.

Before we get started, let’s update src/data.js to have at least 5 stations now. You can just copy the contents from here.

Now, to support this gameplay, we will now have our GameplayMap component take properties just like the map component we used from react-map-gl.

We want to support two properties:

  1. A station property, to let the game component control the list of stations
  2. An onNext property, a customer function we will call to let the game component add customer behavior when a user goes to the next station.
function GameplayMap(props) {                                                                                                                                                                                                                                                         
  // ...
  const station = props.station;

  // ...

  let nextButton = null;
  if (isGuessConfirmed) {
    const score = getScore(guess, station);    
    // ...
    scoreDisplay = <p>Score: {score}</p>;
    nextButton = (
      <button
        onClick={() => {
          setIsGuessConfirmed(false);
          setGuess(null);
          props.onNext({
            latitude: guess.latitude,
            longitude: guess.longitude,
            score: score,
          });
        }}
      >
        Next round
      </button>
    );
  } else {
    // ...
  }

  // ...

  return (
    <div>
      {/*...*/}
      {nextButton}
    </div>
  );
}

Now, we need to implement the Game component which wraps the GameplayMap.

function Game(props) {
const stations = props.stations;
  const [guesses, setGuesses] = useState([]);

  const currentRound = guesses.length;
  const currentStation = stations[currentRound];
  let currentScore = 0;
  for (const guess of guesses) {
    currentScore += guess.score;
  }

  return (
    <div>
      <h3>Round {currentRound + 1} of 5</h3>
      <h3>Score: {currentScore}</h3>
      <GameplayMap
        station={currentStation}
        onNext={(guessData) => {
          // this "..." in front of guesses is called the "spread operator",
          // and it will take every element from the guesses list and add it into the array
          // for example, [...[1, 2], 3] would result in [1, 2, 3]
          setGuesses([...guesses, guessData]);
        }}
      />
    </div>
  );
}

The idea here is that we just need to track a list of guesses as the state. Once we have that list, we can figure out how many guesses have been made and what the total score is.

Finally, we need to call this logic from the app() component which renders the final game:

function App() {
  const stations = _.sampleSize(allStations, 5);


  return (
    <div>
      <Game stations={stations} />
    </div>
  );
}

Take a look at the complete code here.

At this point, we have a nearly-totally-functioning game which should look like this:

Finishing touches: Full station data

Right now we are only playing on a subset of the station data, so let’s fix that. You can copy the remaining data from here.

When the app was initially created, the data was created by querying this data set from the Mass DOT/MBTA, though it took some manual data wrangling to get everything into the right format.

“Game Over” state

The most prominent issue with the game right now is that it has no “game over” concept. In fact, it will actually crash after the final guess. This is a relatively simple fix: we know that the game is over when five guesses have been made. We can implement the “Game Over” screen as follows:

function Game(props) {
  // ...
  const isGameOver = guesses.length === 5;

  if (isGameOver) {
    return (
      <div>
        <h1>Game over!</h1>
        <h1>Score: {currentScore}</h1>
        <button onClick={() => window.location.reload()}>Play again</button>
      </div>
    );
  }

  // Rest of the original code goes here
}

Take a look at the complete code here.

Here, the “Play again” button uses a bit of a cheap trick. The game loses all of its data when the page refreshes, so the “Play again” button is just a reload button. In a more complex version of the app, we would want to actually do the work to reset all of the game state by changing the variables.

Simple styling

A full CSS make-over is outside of the scope of this tutorial, though it’s absolutely something worth giving a shot to hone your own skills. However, to make the game a little more playable, it’s worth adding our own styling to the map to remove some obvious clues.

// towards the top of src/App.js
const mapStyle = {
version: 8,
  name: "Basic",
  metadata: {
    "mapbox:autocomposite": true,
  },
  sources: {
    transit: {
      url: "mapbox://mapbox.transit-v2",
      type: "vector",
    },
    mapbox: {
      url: "mapbox://mapbox.mapbox-streets-v7",
      type: "vector",
    },
  },
  sprite: "mapbox://sprites/mapbox/basic-v8",
  glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf",
  layers: [
    {
      id: "background",
      type: "background",
      paint: {
        "background-color": "#e5e7eb",
      },
      interactive: true,
    },
    {
      id: "landuse_park",
      type: "fill",
      source: "mapbox",
      "source-layer": "landuse",
      filter: ["==", "class", "park"],
      paint: {
        "fill-color": "#d1d5db",
      },
      interactive: true,
    },
    {
      id: "water",
      type: "fill",
      source: "mapbox",
      "source-layer": "water",
      paint: {
       "fill-color": "#9ca3af",
      },
      interactive: true,
    },
  ],
};

Take a look at the complete code here.

Now just replace mapStyle="mapbox://styles/mapbox/streets-v9" with mapStyle={mapStyle} to use these new colors.

Finally, to make the entire look a bit cleaner, we can center the whole game by adding the following:

function App() {
  const stations = _.sampleSize(allStations, 5);

  console.log(stations);

  return (
    <div>
      <center>
        <Game stations={stations} />
      </center>
    </div>
  );
}

Lastly, we can add this to src/App.css to make the button sizing a bit more intuitive.

button {
  font-size: 1em;
}

Take a look at the complete code here.

By now we have a totally functioning version of the game that looks like this:

Conclusion

In this tutorial, we’ve learned how to build a simple game powered by Javascript, React and Mapbox.

There are plenty of features that could be built on top of this foundation. For example:

  1. Storing high scores across multiple games using browser cookies
  2. Improving the styling using CSS
  3. Deploying the app on the public internet using a hosting service
  4. Re-developing the app using data from another city!

You should feel free to add any of these features on your own. As a point of reference, the code powering the original MBTAGuessr game is all available on GitHub, though it uses some more complex development tools and practices.

Ben Muschol
Latest posts by Ben Muschol (see all)

Leave a Reply

Your email address will not be published. Required fields are marked *

Get the latest from Storybench

Keep up with tutorials, behind-the-scenes interviews and more.