Create a two-player memory game with React Native

  • Wern Ancheta
November 6th, 2018
You will need Node, Yarn, ngrok and Expo installed on your machine. Some knowledge of React Native development is required.

In this tutorial, we’ll build a realtime memory game app with React Native and Pusher Channels.

Prerequisites

Basic knowledge of React Native is required.

To easily test the app on multiple devices, we’ll be using Expo to create it. Be sure to install their Android or iOS client apps. Log in to your Expo account on both the CLI and client apps. Create one if you don’t have it already.

These are the package versions used in creating the app:

  • Node 8.3.0
  • Yarn 1.7.0
  • Expo CLI 2.1.2
  • Expo SDK 30.0.0
  • Pusher 4.3.1

Lastly, you also need a Pusher and ngrok account. We’ll use it to create a Pusher app instance and expose the local Pusher server to the internet.

App overview

We’re going to build a two-player memory game app.

When they open the app, users will be greeted by a login screen where they have to enter their username:

When a user logs in, the app’s server component will pick two random users and they’re the ones who will play.

Once an opponent is found, both users are automatically navigated to the Game screen. By default, there will be a lot of question mark icons, these represent the cards that haven’t been opened yet:

To play the game, users have to click on each one to reveal its content. Users can reveal the content of up to two cards before both of them closes. Once a user has selected a pair, they will stay open for the rest of the game.

Here’s what the screen looks like while the two players are playing the game:

When one of the players accumulates the maximum score (in this case it’s 12), both players are notified that one of them already won and the UI is reset:

You can view the app’s source code in this GitHub repo.

Create a Pusher app

You need to create a Pusher app instance so you can use Pusher’s services.

On your Pusher account dashboard, create a new Pusher app and check the Enable client events in the app settings. This allows us to emit events directly from the app:

Clone the starter project

So that we can focus on the main parts of the app, clone the app’s GitHub repo and switch to the starter branch:

    git clone https://github.com/anchetaWern/RNMemory
    cd RNMemory
    git checkout starter

The starter branch contains all the screens of the app (Login and Game screen), helper functions, and the UI components that we will be using. The app screens and the server component are where we will do most of the work.

Next, install all the packages the app and the server depend on:

    yarn install
    cd server
    npm install

Starter project tour

Before we proceed to actually code the app, let’s first take a quick look at the files and folders that are already available in the starter project:

  • app/screens - this is where the app’s screens are stored. We only have two screens: Login and Game. Only a starter template with minimum output is available in the starter branch.
  • app/helpers - this is where the app’s helper functions are stored. In this case, we only have the shuffleArray.js file. It’s used to arrange the items in the array in random order. This is very useful for changing the location of the cards that need to be guessed in the screen.
  • app/data - this is where hard-coded data is stored. In this case, we only have the cards.js file which contains the definition for the unique cards that the app uses. We’re using Expo’s vector icons so the file contains the name of the icon, the src (for example, FontAwesome), and the color we want to apply to it.
  • app/components - this is where the UI components of the app are stored. In this case, we only have the Card.js which renders the individual cards, and Score.js which renders the score and name of the user.
  • server - this is where we will add the code for the server component of the app. Currently, it only has the .env file which contains the placeholder for Pusher app credentials.

Build the app

We’re now ready to start building the app. I encourage you to start running the app even if the code is still not complete. This allows you to visualize what the code does as we’re adding it:

    expo start

Login screen

In the Login screen, start by importing all the packages we need:

    // app/screens/Login.js
    import React, { Component } from "react";
    import {
      View,
      Text,
      TextInput,
      Button,
      Alert,
      ActivityIndicator
    } from "react-native";

    import Pusher from "pusher-js/react-native";

    // next: initialize state

Next, initialize the state and the user’s channel:

    state = {
      username: "",
      is_loading: false
    };

    constructor(props) {
      super(props);
      this.pusher = null; // variable for storing the Pusher reference
      this.my_channel = null; // variable for storing the channel assigned to this user
    }

    // next: add render function

Inside the render function, return the UI for the Login screen. In this case, we’re only asking the user’s username. The login button is visible by default, but it gets hidden in place of the activity indicator when the user clicks on it. It stays that way until such time that an opponent is found:

    render() {
      return (
        <View style={styles.container}>
          <View style={styles.topContent}>
            <Text style={styles.bigText}>RNMemory</Text>
          </View>

          <View style={styles.mainContent}>
            <Text style={styles.label}>Username</Text>
            <TextInput
              style={styles.text_field}
              onChangeText={username => {
                this.setState({ username });
              }}
              value={this.state.username}
              placeholder="Enter your username"
            />

            {!this.state.is_loading && (
              <Button onPress={this.login} title="Enter" color="#0064e1" />
            )}

            {this.state.is_loading && (
              <ActivityIndicator size="large" color="#0000ff" />
            )}
          </View>
        </View>
      );
    }

When the login button is clicked, the login function gets executed. This will connect the user to Pusher and subscribe them in their own unique channel. Subscribing the user to their own channel allows us to:

  • Send data to them once an opponent is found.
  • While the game is ongoing, we use it to receive updates of the opponent’s score.
  • When the current user wins, we use it to send an update to the opponent that they lose.

Here’s the code:

    login = () => {
      let username = this.state.username;

      if (username) {
        this.setState({
          is_loading: true // hide's login button and shows activity indicator
        });

        // connect to Pusher:
        this.pusher = new Pusher("YOUR_PUSHER_API_KEY", {
          authEndpoint: "YOUR_NGROK_URL/pusher/auth",
          cluster: "YOUR_PUSHER_APP_CLUSTER",
          encrypted: true,
          auth: {
            params: { username: username }
          }
        });

        this.my_channel = this.pusher.subscribe(`private-user-${username}`); // subscribe to user's unique channel

        // subscription error occurred
        this.my_channel.bind("pusher:subscription_error", status => {
          Alert.alert('Error', 'Subscription error occurred. Please restart the app');
        });

        // subscription to their own channel succeeded
        this.my_channel.bind("pusher:subscription_succeeded", data => {
          console.log("subscription ok: ", data);

          // next: add code for listening when opponent is found    
        });
      }
    };

Don’t forget to add your Pusher App ID when connecting to Pusher. As for the authEndpoint, we’ll add it later once we run ngrok.

Next, add the code for listening for the opponent-found event. This event is emitted from the server once two random users are matched. Both players are notified when that happens, and it will automatically navigate the user to Game screen:

    this.my_channel.bind("opponent-found", data => {
      console.log("opponent found: ", data);

      // determine who the opponent is, player one or player two?
      let opponent =
        username == data.player_one ? data.player_two : data.player_one;

      Alert.alert("Opponent found!", `${opponent} will take you on!`);

      this.setState({
        is_loading: false,
        username: ""
      });

      // navigate to the game screen
      this.props.navigation.navigate("Game", {
        pusher: this.pusher, // Pusher connection
        username: username, // current user's username
        opponent: opponent, // opponent's username
        my_channel: this.my_channel // current user's channel
      });
    });

Game screen

We now move on to the Game screen. Start by importing the packages, helpers, data, and components we need:

    // app/screens/Game.js
    import React, { Component } from "react";
    import { View, Text, Button, FlatList, Alert } from "react-native";
    import { FontAwesome, Entypo } from "@expo/vector-icons"; // the icon sources that the cards will use

    import Score from "../components/Score";
    import Card from "../components/Card";

    import shuffleArray from "../helpers/shuffleArray"; // function for re-ordering the cards to be guessed

    import cards_data from "../data/cards"; // the unique card config

    // next: initialize state

Next, initialize the state:

    state = {
      current_selection: [], // for storing the currently selected pairs. This always resets back to zero once two are selected 
      selected_pairs: [], // the pairs that had already been opened 
      score: 0, // current user's score
      opponent_score: 0 // opponent's score
    };

    // next: add the constructor

Next, add the constructor. This is where we add initial values for the Pusher channels and generate the cards, which will be rendered on the screen:

    constructor(props) {
      super(props);

      this.pusher = null; // Pusher connection
      this.my_channel = null; // current user's Pusher channel
      this.opponent_channel = null; // opponent's Pusher channel
      this.username = null; // current user's username
      this.opponent = null; // opponent's username

      let sources = {
        fontawesome: FontAwesome,
        entypo: Entypo
      };

      let clone = JSON.parse(JSON.stringify(cards_data)); // create a copy of the cards data

      this.cards = cards_data.concat(clone); // append the copy to its original

      // add a unique ID to each of the card
      this.cards.map(obj => {
        let id = Math.random()
          .toString(36)
          .substring(7);
        obj.id = id;
        obj.src = sources[obj.src];
        obj.is_open = false;
      });

      this.cards = shuffleArray(this.cards); // arrange the cards in random order
    }

    // next: add componentDidMount

Once the component is mounted, we pick up the navigation params that were passed from the Login screen earlier. This allows us to listen for events emitted by the opponent, and emit events from our own channel:

    componentDidMount() {
      const { navigation } = this.props;

      // get Pusher connection and user's channel from the navigation param
      this.pusher = navigation.getParam("pusher");
      this.my_channel = navigation.getParam("my_channel");

      this.username = navigation.getParam("username");
      this.opponent = navigation.getParam("opponent");

      // update the state with the cards generated inside the constructor earlier
      this.setState({
        cards: this.cards
      });

      if (this.opponent) {
        // subscribe to the opponent's channel
        this.opponent_channel = this.pusher.subscribe(
          `private-user-${this.opponent}`
        );
        this.opponent_channel.bind("pusher:subscription_error", status => {
          Alert.alert('Subscription error', 'Please restart the app');
        });

        this.opponent_channel.bind("pusher:subscription_succeeded", data => {
          console.log("opponent subscription ok: ", data);

          // opponent's score is incremented
          this.opponent_channel.bind("client-opponent-scored", data => {
            this.setState({
              opponent_score: data.score
            });
          });

          // opponent won the game
          this.opponent_channel.bind("client-opponent-won", data => {
            Alert.alert("You lose", `${data.username} won the game`);
            this.resetCards(); // close all the cards and reset the score
          });
        });
      }
    }

In the code above, we’re listening for the client-opponent-scored event and the client-opponent-won event. The former allows the user to get updated of their opponent’s score. This is emitted by their opponent every time they open a matching pair. The latter allows the user to get updated when their opponent wins the game. When this happens, we call the resetCards method to reset the UI. This allows both users to restart the game if they want.

Next, we render the Game screen. Here, we use a FlatList to render all the cards. Below it, we use the Score component to render the username and score for both users:

    render() {
      let contents = this.state.cards;

      return (
        <View style={styles.container}>
          <View style={styles.body}>
            <FlatList
              data={contents}
              renderItem={this.renderCard}
              numColumns={4}
              keyExtractor={item => item.id}
              columnWrapperStyle={styles.flatlistRow}
            />
          </View>
          <View style={styles.bottomContent}>
            <Score score={this.state.score} username={this.username} />
            <Score score={this.state.opponent_score} username={this.opponent} />
          </View>
        </View>
      );
    }

The renderCard function renders each individual card. This is where we use the Card component to render each card. We pass in the data which determines how the card will look like as props. The clickCard prop is where we pass the function to be executed when the card is clicked:

    renderCard = ({ item }) => {
      return (
        <Card
          key={item.id}
          src={item.src}
          name={item.name}
          color={item.color}
          is_open={item.is_open}
          clickCard={this.clickCard.bind(this, item.id)}
        />
      );
    };

Here’s the clickCard function. This is where we process the card opened by the user. We only process cards that aren’t already guessed by the user and is not already opened. If both conditions are true, we add the card to the array of currently selected cards (selected_pairs):

    clickCard = id => {
      let selected_pairs = [...this.state.selected_pairs]; // array containing the card pairs that had already been guessed by the user
      let current_selection = this.state.current_selection; // array containing the currently selected cards
      let score = this.state.score; // the user's current score

      // get the index of the card clicked by the user
      let index = this.state.cards.findIndex(card => {
        return card.id == id;
      });

      let cards = [...this.state.cards]; // an array containing the cards rendered on the screen

      if (
        cards[index].is_open == false &&
        selected_pairs.indexOf(cards[index].name) === -1
      ) { // only process the cards that isn't currently open and is not a part of the one's that have already been guessed by the user
        cards[index].is_open = true; // open the card

        // add the card in the current selection
        current_selection.push({
          index: index,
          name: cards[index].name
        });

        // next: add code for checking if there are already two cards opened        
      }
    };

    // next: add code for resetting cards

Once the user has already picked a pair of cards, we check whether their names are the same. If it does, we increment the user’s score by one and notify the opponent by emitting an event in the current user’s channel. This works because we’ve set up the current user to listen for events emitted from their opponent’s channel. So anytime we trigger an event on our own channel, the opponent gets notified. Once the current user accumulates the maximum score, we reset the UI and notify the opponent. On the other hand, if the pair selected by the user isn’t the same, we update the state to close the cards:

    if (current_selection.length == 2) {
      if (current_selection[0].name == current_selection[1].name) {
        score += 1;
        selected_pairs.push(cards[index].name);

        // notify the opponent that their opponent have scored
        this.my_channel.trigger("client-opponent-scored", {
          username: this.username,
          score: score
        });

        // all pairs have been opened
        if (score == 12) {
          // notify the user that they won
          score = 0;
          Alert.alert("Awesome!", "You won the game");
          // notify the opponent that they lose
          this.my_channel.trigger("client-opponent-won", {
            username: this.username
          });

          this.resetCards(); // reset the UI
        }
      } else {
        cards[current_selection[0].index].is_open = false; // close the first card from the selected pair

        // close the second card from the selected pair after half a second
        setTimeout(() => {
          cards[index].is_open = false;
          this.setState({
            cards: cards
          });
        }, 500);
      }

      current_selection = []; // reset the current selection
    }

    // update the state
    this.setState({
      score: score,
      cards: cards,
      current_selection: current_selection
    });

Lastly, the resetCards function is where we reset the UI so the users can resume the game if they want to:

    resetCards = () => {
      // close all cards
      let cards = this.cards.map(obj => {
        obj.is_open = false;
        return obj;
      });

      // re-shuffle the cards
      cards = shuffleArray(cards);

      // update the state to reset the UI
      this.setState({
        current_selection: [],
        selected_pairs: [],
        cards: cards,
        score: 0,
        opponent_score: 0
      });
    };

Server component

Now we’re ready to add the server component. This is where we add the code for authenticating users as well as matching them so they can start playing the game.

Create a server.js file inside the server folder and start importing the packages we need and initialize them:

    var express = require("express");
    var bodyParser = require("body-parser");
    var Pusher = require("pusher"); // for authenticating users and emitting events from this server

    var app = express(); // for setting up the server
    app.use(bodyParser.json()); // for parsing request body into JSON
    app.use(bodyParser.urlencoded({ extended: false })); // for parsing URL encoded data in the request body

    require("dotenv").config(); // for getting the environment variables

Next, initialize the array of users. This is where we will store the usernames of the users who log in to the app:

    var users = [];

Next, initialize the Pusher connection using the credentials from the .env file:

    var pusher = new Pusher({
      appId: process.env.APP_ID,
      key: process.env.APP_KEY,
      secret: process.env.APP_SECRET,
      cluster: process.env.APP_CLUSTER
    });

If you haven’t done so already, this is a good chance to update the server/.env file with your Pusher app instance credentials:

    APP_ID=YOUR_PUSHER_APP_ID
    APP_KEY=YOUR_PUSHER_APP_KEY
    APP_SECRET=YOUR_PUSHER_APP_SECRET
    APP_CLUSTER=YOUR_PUSHER_APP_CLUSTER
    PORT=3000

Next, add a route for checking if the server is running. Try to access this later at http://localhost:3000 on your browser once the server is running:

    app.get("/", function(req, res) {
      res.send("all green...");
    });

Next, add the function for returning a random integer (from zero) that’s not greater than the max passed as an argument:

    function randomArrayIndex(max) {
      return Math.floor(Math.random() * max);
    }

Next, add the route which receives the Pusher authentication request. From the login code of the app/screens/Login.js file earlier, we added the username in auth.params. This is what we’re accessing in the request body. A username should be unique so we first check if it already exists before processing the request further. If it doesn’t yet exist, we push it to the users array. Once there are at least two users, we pick two random users from there. Those two users will be the ones who will partake in the game. We trigger the opponent-found event on each of the user’s channel. The event contains the username of the two users. This allowed us to determine which of the users is the opponent and which is the current user from the app/screens/Game.js file earlier. Once that’s done, we authenticate the two users and return the authentication token as the response:

    app.post("/pusher/auth", function(req, res) {
      var username = req.body.username; // get the username passed as an additional param

      if (users.indexOf(username) === -1) {
        users.push(username);

        if (users.length >= 2) {
          var player_one_index = randomArrayIndex(users.length);
          var player_one = users.splice(player_one_index, 1)[0]; // pick a random user and remove them from the array

          var player_two_index = randomArrayIndex(users.length);
          var player_two = users.splice(player_two_index, 1)[0]; // pick a random user and remove them from the array

          // trigger a message to player one and player two on their own channels
          pusher.trigger(
            ["private-user-" + player_one, "private-user-" + player_two],
            "opponent-found",
            {
              player_one: player_one,
              player_two: player_two
            }
          );
        }

        // authenticate the user
        var socketId = req.body.socket_id;
        var channel = req.body.channel_name;
        var auth = pusher.authenticate(socketId, channel);

        res.send(auth); // return the auth token
      } else {
        res.status(400);
      }
    });

Lastly, serve it on the port you’ve specified in your server/.env file:

    var port = process.env.PORT || 5000;
    app.listen(port);

Run the app

At this point, we should be ready to run the server and expose it to the internet.

Execute the following inside the server directory to run the server:

    node server.js

Next, navigate to where you downloaded the ngrok executable file and execute the following:

    ./ngrok http 3000

Try if the server is running correctly by accessing the https URL returned by ngrok on your browser. If it says “all green…”, the next step is to add the ngrok URL to your app/screens/Login.js file:

    this.pusher = new Pusher("YOUR PUSHER APP ID", {
      authEndpoint: "YOUR_NGROK_URL/pusher/auth",
      cluster: "YOUR PUSHER APP CLUSTER",
      encrypted: true,
      auth: {
        params: { username: username }
      }
    });

Once that’s done, the app should work fully:

    expo start

Conclusion

That’s it! In this tutorial, we created a two-player memory game with React Native and Pusher. Along the way, you learned how to use Pusher in React Native. Specifically, you learned how to emit events from both the server and the client side.

You can view the app’s source code in this GitHub repo.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.