Online Text RPG – Entering The World (Part 1)

To embrace more modern development practices, we are going to build our game as a web-based interface, allowing anyone with a modern web browser to join. We will use Pusher as the mechanism allowing players to get immediate updates of anything happening in the game world, with a frontend written using React, and a GraphQL backend built on Node.js controlling the game itself. The interface to our backend will be primarily a GraphQL API, allowing for a rich interface between web app and server.
This article is part of Build an online text RPG with React and GraphQL course.
Throughout these tutorials, we are going to build a relatively simple online role-playing game, similar to the MUD games in the 1970s and 1980s which are themselves the predecessors to the modern MMO games millions of people play today. Our game will allow players to log in with a character name, explore the world and interact with both the world and other players.
By the end of this article, we will have a game world that players can connect to, and see other players in the same area as themselves. We will be using the Pusher Presence Channels to determine which players are connected and in each area. This requires an authentication server to ensure that the clients are actually allowed to connect to the channels.
The game at the end of this article will look as follows:
Prerequisites
In order to follow this article, you will need to have a modern version of Node.js installed. You should also have an understanding of working with React, as they will feature heavily in the end result.
You will also need to have created a Pusher account and registered an Application. Upon doing so, you will receive an App ID, Key, Secret, and Cluster. These will be needed later on to connect to Pusher from our Backend and Frontend applications.
Writing the Backend Server
The first thing we will do is set up our backend server. This will have the following purposes:
- Act as the Authentication Server necessary for Pusher Presence Channels to work.
- Allow the web app to inform the server of a new player
We will write our server using Express and Apollo Server, set up using Express Generator.
Setting up Express
Express Generator allows for a very quick way to get an Express server started. Firstly, make sure this is installed:
$ npm install -g express-generator
~/.nvm/versions/node/v8.2.1/bin/express -> ~/.nvm/versions/node/v8.2.1/lib/node_modules/express-generator/bin/express-cli.js
+ [email protected]
added 6 packages in 1.105s
Once this is done, a new project can be started:
$ express pusher-mud-backend
warning: the default view engine will not be jade in future releases
warning: use `--view=jade' or `--help' for additional options
create : pusher-mud-backend
create : pusher-mud-backend/package.json
create : pusher-mud-backend/app.js
create : pusher-mud-backend/public
create : pusher-mud-backend/routes
create : pusher-mud-backend/routes/index.js
create : pusher-mud-backend/routes/users.js
create : pusher-mud-backend/views
create : pusher-mud-backend/views/index.jade
create : pusher-mud-backend/views/layout.jade
create : pusher-mud-backend/views/error.jade
create : pusher-mud-backend/bin
create : pusher-mud-backend/bin/www
create : pusher-mud-backend/public/javascripts
create : pusher-mud-backend/public/stylesheets
create : pusher-mud-backend/public/stylesheets/style.css
install dependencies:
$ cd pusher-mud-backend && npm install
run the app:
$ DEBUG=pusher-mud-backend:* npm start
create : pusher-mud-backend/public/images
$ cd pusher-mud-backend
$ npm install
npm WARN deprecated [email protected]: Jade has been renamed to pug, please install the latest version of pug instead of jade
npm WARN deprecated [email protected]: Deprecated, use jstransformer
added 102 packages in 3.514s
? The warnings about the default view engine can be ignored, since we do not intend to use them at all.
There are some parts of this that we do not need and can safely remove. You can safely delete:
- The
public
directory. - The
views
directory. - The
routes/users.js
file.
And, to go along with this, delete the following lines from app.js
:
var users = require('./routes/users');
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
app.use(express.static(path.join(__dirname, 'public')));
app.use('/users', users);
Finally, we want to change the default port that is being used. This is because by default this app will be using the same port as our frontend, which will cause conflicts. This is done by editing bin/www
and changing it as follows:
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '4000');
app.set('port', port);
The new port value that was selected was 4000, but you can use anything that you like as long as it’s free.
Setting up CORS
Cross-Origin Resource Sharing (CORS) allows for a webpage running on one server to connect to a web application running on another server. Without this, the browser’s security model will block these requests as insecure.
For our needs, we can simply allow all incoming requests to our server. In reality, this might not be the best fit security-wise, and you will need to decide how best to manage that at that point.
In order to support CORS, we will use the cors
module:
$ npm install --save cors
npm notice created a lockfile as package-lock.json. You should commit this file.
+ [email protected]
added 3 packages in 1.323s
Once installed, we configure it by adding the following lines to app.js
:
// At the top, with the other require statements
var cors = require('cors');
......
// Lower down, in the various app.use() lines
app.use(cors());
Setting up GraphQL
Now that we have our Express server, we can add GraphQL in. This will be done using Apollo Server, which is a powerful GraphQL setup that is very easy to use with Express.
First, we need to install the appropriate modules:
$ npm install --save apollo-server-express graphql graphql-tools
+ [email protected]
+ [email protected]
+ [email protected]
added 15 packages in 2.999s
And then set up our server to actually accept our GraphQL requests. GraphQL works by defining a schema that the server works against, and then by attaching Resolvers to points in the schema. We will be using GraphQL Tools to define our schema and attach our resolvers, as this allows us to do so in a very easy-to-read and flexible way.
All of our GraphQL handlings will live in routes/graphql.js
for this example. Realistically, you would want to split it over multiple files for easy maintenance, but that is out of scope here. Create this file and put the following into it:
const graphqlTools = require('graphql-tools');
const GraphqlSchema = `
type Query {
version: String
}
type Mutation {
signin(sessionId: ID!, name: String!, race: String!, class: String!): String
}
`;
const resolvers = {
Query: {
version: () => '1.0.0'
},
Mutation: {
signin: (_, user) => {
return "Success";
}
}
};
const builtSchema = graphqlTools.makeExecutableSchema({
typeDefs: GraphqlSchema,
resolvers
});
module.exports = builtSchema;
There’s quite a lot going on here, so let’s take it one piece at a time.
To start with, we define the GraphQL Schema. This is done using the GraphQL Schema Language. Our schema defines one Query field and one Mutation field:
- version – This is a simple helper to check that the correct API version is present.
- signin – This will take some user details – including a Session ID – and register this user as logged in. This will be important for the Pusher Authentication endpoint later on.
Next, we define our resolvers. This is a JavaScript object where the outermost keys are the GraphQL type that is being resolved, and each of those is itself an object containing a GraphQL field to resolver function. This can be read in more detail in the GraphQL Tools documentation.
Note that for now, our resolvers don’t do anything useful. This will change soon though.
Finally, we need to actually build the GraphQL Schema. This is done using the graphqlTools.makeExecutableSchema
function, which takes our Schema definition and our Resolvers and builds a live GraphQL Schema object that can be used by the running server.
After all of this, we need to wire it up into the server. This is done inside app.js
, as follows:
// At the top, with the other require statements
var apollo = require('apollo-server-express');
var graphqlSchema = require('./routes/graphql');
// Lower down, with the route handlers
app.use('/graphql', apollo.graphqlExpress({ schema: graphqlSchema }));
app.use('/graphiql', apollo.graphiqlExpress({ endpointURL: '/graphql' }));
This ultimately registers two different URL handlers:
/graphql
– This handles the actual GraphQL Query requests/graphiql
– This displayed the GraphiQL development tools, which are very useful for testing the system.
The application could now be started up and will work, though not doing anything useful yet:
$ npm start
> [email protected] start ~/source/pusher-mud-backend
> node ./bin/www
Here we can see the GraphiQL interface proving that we can request our version
field and be getting the correct value back.
Allowing Characters to Sign In
The final action we need on our GraphQL endpoint is the ability for a character to inform the server that they are signing in. This will pass along a Session ID – which we will later see is our Pusher Socket ID – along with the Character Name, Race, and Class.
For this, we will use a fake user data store. This could be implemented using a real session storage strategy, or a system such as Redis, but for this article that is unnecessary.
Create a new file called users.js
with the following contents:
const users = {};
function registerUser(sessionId, name, race, cls) {
users[sessionId] = {
name: name,
race: race,
class: cls
};
}
function getUser(sessionId) {
return users[sessionId];
}
module.exports = {
registerUser: registerUser,
getUser: getUser
};
Very simply, this gives us two methods that we can use:
- registerUser – This adds a new user to the store
- getUser – This retrieves an existing user from the store
Next, we wire this up to the GraphQL endpoint. This is simply a case of calling our registerUser
function from the signin
Mutation. Update routes/graphql.js
as follows:
// At the top with the requires section
var users = require('../users');
// In the GraphQL Resolvers
Mutation: {
signin: (_, user) => {
users.registerUser(user.sessionId, user.name, user.race, user.class);
return "Success";
}
}
This allows the signin
Mutation to call our registerUser
function and then return a Success message. In reality, there might be more checks on this, but that is unnecessary here.
Pusher Authentication Endpoint
We now need an endpoint that Pusher will call on our server to ensure that a user is allowed access to our Presence channel, and to return the user information to include on that channel subscription. This will check that the user exists and nothing more.
Firstly we need the Pusher module available to use. This allows our server to communicate with the Pusher service and generate authentication tokens:
# npm install --save pusher
npm WARN deprecated node-[email protected]: Use uuid module instead
+ [email protected]
added 70 packages in 4.098s
Now we need to add our new route file. Create a file called routes/pusher.js
with the following information:
var express = require('express');
var router = express.Router();
var Pusher = require('pusher');
var users = require('../users');
var pusher = new Pusher({
appId: 'PUSHER_APP_ID',
key: 'PUSHER_KEY',
secret: 'PUSHER_SECRET',
cluster: 'PUSHER_CLUSTER'
});
/* GET home page. */
router.post('/auth', function(req, res, next) {
var socketId = req.body.socket_id;
var channel = req.body.channel_name;
var user = users.getUser(socketId);
var presenceData = {
user_id: socketId,
user_info: {
name: user.name,
race: user.race,
class: user.class
}
};
var auth = pusher.authenticate(socketId, channel, presenceData);
res.send(auth);
});
module.exports = router;
Replace the placeholders PUSHER_APP_ID, PUSHER_KEY, PUSHER_SECRET, and PUSHER_CLUSTER with the values obtained earlier.
This creates a new route on /auth
that accepts a Socket ID and Channel Name, looks up the User information using the Socket ID as the key, and then calls to Pusher to authenticate the connection.
Now we need to register this into our application. Update the app.js
file as follows:
// Up the top with the other require statements
var pusher = require('./routes/pusher');
// Lower down with the routes
app.use('/pusher', pusher);
The end result of this is that the Frontend application will be able to use /pusher/auth
in order to authenticate a Pusher connection for use on our Presence channel, as long as the Socket ID for that Pusher Connection has previously called our signin
mutation on our GraphQL server.
Writing the Frontend
Now that we have our Backend working, we need a UI to go with it. This will be built using React and Bootstrap and using the Apollo Client and Pusher libraries to communicate with the backend.
Setting up React
In order to set up our frontend, we will use Create React App. This very quickly allows us to get started on our User Interface with very little work needed.
Firstly, ensure that this is installed:
> npm install -g create-react-app
~/.nvm/versions/node/v8.2.1/bin/create-react-app -> ~/.nvm/versions/node/v8.2.1/lib/node_modules/create-react-app/index.js
+ [email protected]
added 106 packages in 4.77s
Once finished, use it to bootstrap our application:
> create-react-app pusher-mud-frontend
Creating a new React app in ~/source/pusher-mud-frontend.
Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts...
> [email protected] install ~/source/pusher-mud-frontend/node_modules/fsevents
> node install
[fsevents] Success: "~/source/pusher-mud-frontend/node_modules/fsevents/lib/binding/Release/node-v57-darwin-x64/fse.node" already installed
Pass --update-binary to reinstall or --build-from-source to recompile
> [email protected] postinstall ~/source/pusher-mud-frontend/node_modules/uglifyjs-webpack-plugin
> node lib/post_install.js
+ [email protected]
+ [email protected]
+ [email protected]
added 1266 packages in 32.058s
Success! Created pusher-mud-frontend at ~/source/pusher-mud-frontend
Inside that directory, you can run several commands:
npm start
Starts the development server.
npm run build
Bundles the app into static files for production.
npm test
Starts the test runner.
npm run eject
Removes this tool and copies build dependencies, configuration files
and scripts into the app directory. If you do this, you can’t go back!
We suggest that you begin by typing:
cd pusher-mud-frontend
npm start
Happy hacking!
$ cd pusher-mud-frontend
$ npm start
Compiled successfully!
You can now view pusher-mud-frontend in the browser.
Local: http://localhost:3000/
On Your Network: http://192.168.0.15:3000/
Note that the development build is not optimized.
To create a production build, use npm run build.
We now have a fully-functional React webapp that we can work with. You should have even seen it open in your web browser, but if not you can simply visit the links output:
Before anything else though, we will install some extra modules that we are going to need. After that, we can leave the server running and it will hot-reload changes as we make them:
> npm install --save pusher-js apollo-client-preset graphql graphql-tag react-apollo
npm WARN [email protected] requires a peer of [email protected]^1.0.3 but none is installed. You must install peer dependencies yourself.
+ [email protected]
+ [email protected]
+ [email protected]
+ [email protected]
+ [email protected]
added 24 packages in 11.576s
Finally, we will be using Bootstrap 4 for our look and feel. This is easily introduced by simply adding the CDN links to our Index page. Edit public/index.html
and add the following inside the <head>
section:
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css" integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js" integrity="sha384-alpBpkh1PFOepccYVYDB4do5UnbKysX5WZXm3XxPqe5iKTfUKjNkCk9SaVuEZflJ" crossorigin="anonymous"></script>
Once done, restart the application using npm start
and leave it running. You should also ensure that your backend application is still running as we will soon be depending on that.
Character Creation Screen
The first screen we will create is our Character Creation screen. This is the screen the player will be presented with on loading the page and will allow them to enter their Character Name, Race and Class.
We are going to be lazy and use the Character Race and Class names from the Dungeons & Dragons 3.5 SRD.
The first thing to do is to create some data files. These are shared between a couple of screens, so abstracting it out makes things a bit easier for us.
In the src
directory, create a file called races.js
containing:
const races = [
{
id: 'human',
name: 'Human'
}, {
id: 'dwarf',
name: 'Dwarf'
}, {
id: 'elf',
name: 'Elf'
}, {
id: 'gnome',
name: 'Gnome'
}, {
id: 'halfelf',
name: 'Half-Elf'
}, {
id: 'halforc',
name: 'Half-Orc'
}, {
id: 'halfling',
name: 'Halfling'
}
];
export default races;
And another file in the same directory called classes.js
containing:
const classes = [
{
id: 'fighter',
name: 'Fighter'
}, {
id: 'cleric',
name: 'Cleric'
}, {
id: 'ranger',
name: 'Ranger'
}, {
id: 'rogue',
name: 'Rogue'
}, {
id: 'wizard',
name: 'Wizard'
}, {
id: 'barbarian',
name: 'Barbarian'
}, {
id: 'bard',
name: 'Bard'
}, {
id: 'druid',
name: 'Druid'
}, {
id: 'monk',
name: 'Monk'
}, {
id: 'paladin',
name: 'Paladin'
}, {
id: 'sorcerer',
name: 'Sorcerer'
}
];
export default classes;
In reality, this data would be loaded from the server, so that it can be adjusted as necessary. However, for this example, this is more than adequate.
Next, we need the actual component for the Character Creation screen. This will be in src/Login.js
and will contain:
import React, { Component } from 'react';
import races from './races';
import classes from './classes';
class Login extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
race: 'human',
cls: 'fighter'
};
this._handleLogin = this._onLogin.bind(this);
this._handleNameChange = this._onNameChange.bind(this);
this._handleRaceChange = this._onRaceChange.bind(this);
this._handleClassChange = this._onClassChange.bind(this);
}
render() {
const { name, race, cls } = this.state;
const racesOptions = races.map((race) => <option value={race.id}>{race.name}</option>);
const classesOptions = classes.map((cls) => <option value={cls.id}>{cls.name}</option>);
return (
<div className="row justify-content-center">
<div className="col-sm-6 col-md-4">
<div className="card">
<div className="card-body">
<h4 className="card-title">Join Game</h4>
<form onSubmit={ this._handleLogin }>
<div className="form-group">
<label htmlFor="characterName">Name</label>
<input type="text" className="form-control" id="characterName" placeholder="Enter name" value={ name } onChange={ this._handleNameChange }/>
</div>
<div className="form-group">
<label htmlFor="characterRace">Race</label>
<select id="characterRace" className="form-control" value={ race } onChange={ this._handleRaceChange }>
{ racesOptions }
</select>
</div>
<div className="form-group">
<label htmlFor="characterClass">Class</label>
<select id="characterClass" className="form-control" value={ cls } onChange={ this._handleClassChange }>
{ classesOptions }
</select>
</div>
<div className="form-group">
<input type="submit" className="btn btn-primary" value="Join Game" />
</div>
</form>
</div>
</div>
</div>
</div>
);
}
_onLogin(e) {
const { name, race, cls } = this.state;
e.preventDefault();
this.props.handleLogin(name, race, cls);
}
_onNameChange(e) {
this.setState({name: e.target.value});
}
_onRaceChange(e) {
this.setState({race: e.target.value});
}
_onClassChange(e) {
this.setState({cls: e.target.value});
}
}
export default Login;
This looks complicated, but in actuality, it simply displays a form with three fields on it – one each for Name, Race, and Class – and allows the user to log in with these values. When the user has selected their character details, a callback passed in will be triggered, informing something higher in the React structure that this has happened.
Now we need to update the main App component that renders the core application. For now, this will always render the Login component and nothing else, but that will soon change.
Update src/App.js
as follows:
import React, { Component } from 'react';
import Login from './Login';
class App extends Component {
constructor(props) {
super(props);
this._handleLogin = this._onLogin.bind(this);
}
render() {
return (
<div className="App container-fluid">
<Login handleLogin={ this._handleLogin } />
</div>
);
}
_onLogin(name, race, cls) {
}
}
export default App;
We now have our Login Screen, though it will not do anything yet. If you switch back to your browser you should see that it’s automatically reloaded to show this:
Note that our App
component has a function called _onLogin
. This will be called, passing along the characters Name, Race and Class, when the user presses that “Join Game” button. Soon we will make this actually log the character into the game.
Supporting communications with the Server
In order for our frontend to communicate with the backend server, we need to set up two different communication mechanisms – Pusher and GraphQL.
For the Pusher communications, we will write a file src/pusher.js
containing the following:
import Pusher from 'pusher-js';
const socket = new Pusher('PUSHER_KEY', {
cluster: 'PUSHER_CLUSTER',
encrypted: true,
authEndpoint: 'http://localhost:4000/pusher/auth'
});
export default socket;
Note the authEndpoint
property. This must point to our /pusher/auth
route on our running server, wherever that is deployed.
Again, make sure to replace PUSHER_APP_ID and PUSHER_CLUSTER with the correct values from our Pusher dashboard.
Next, we will write a file src/graphql.js
containing the following:
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
const client = new ApolloClient({
link: new HttpLink({ uri: 'http://localhost:4000/graphql' }),
cache: new InMemoryCache()
});
export default client;
This time, the link
property points to the /graphql
route on the same deployed server.
These will give us everything we need to enable our application to communicate with the server.
Calling the Server to Sign In
When the user enters their character details and signs in, we need to inform the server of this fact. And, more importantly, we need to have done so before the frontend client tries to communicate with Pusher on any channels that need authentication.
We will do this from the _onLogin
callback in our src/App.js
file, making a call to our signin
mutation on our GraphQL endpoint and then setting the component state to reflect the fact that the player has signed in. This will later be used to change the view that is displayed to reflect the fact that we are now in the world.
In order to achieve this, we need to make the following changes, all in src/App.js
.
First, some more module imports are needed:
import pusher from './pusher';
import graphqlClient from './graphql';
import gql from 'graphql-tag';
Then, a GraphQL statement that will be executed. Place this above the App
class but after the require
statements:
const SIGN_IN_MUTATION = gql`mutation($sessionId: ID!, $name:String!, $race:String!, $class:String!) {
signin(sessionId: $sessionId, name:$name, race:$race, class:$class)
}
`;
This will be used to call the signin
mutation and is parameterized so that our code can always execute this same statement and simply provide the appropriate values from our character.
Finally, update the _onLogin
method as follows:
_onLogin(name, race, cls) {
graphqlClient.mutate({
mutation: SIGN_IN_MUTATION,
variables: {
sessionId: pusher.connection.socket_id,
name: name,
race: race,
class: cls
}
}).then(() => {
this.setState({
player: {
name,
race,
cls
}
});
});
}
This makes a call to our GraphQL endpoint, executing our provided statement with our character details, and afterward it sets a state variable called player
containing the player details.
Note that we provide the Socket ID from our Pusher connection as the sessionId
to our GraphQL call. This is consistent with what we saw earlier, and it ensures that we are using the Pusher connection ID as our session identifier throughout the entire application.
If we were to try this now, we would see network traffic making this GraphQL call:
Game Screen
Now we’re able to create a character and sign in, we need to display the game to the player. Our game screen will be separated into 4 quadrants, displaying:
- A description of the current room.
- Details of the current player.
- A message log of events that have happened in the game.
- A list of characters in the current room.
To achieve this, we will have a new component for the game window, and render this once a character is selected.
Create a new file called src/Game.js
as follows:
import React, { Component } from 'react';
import './Game.css';
class Game extends Component {
render() {
return (
<div className="row">
<div className="col-8">
<div className="game-roomDescription">
Room Description Here
</div>
<div className="game-messageLog">
Message Log Here
</div>
<div>
<input type="text" className="form-control" placeholder="Enter command" />
</div>
</div>
<div className="col-4">
<div className="game-characterDetails">
Character Details Here
</div>
<div className="game-playerList">
Player List Here
</div>
</div>
</div>
);
}
}
export default Game;
Notice at the top that we are importing a file called Game.css
. Create React App has set up WebPack so that CSS files can be distributed alongside components in this manner, and will be automatically pulled together. In this case, we are adding some CSS to our component to make sure that everything is in the correct position. This file is in src/Game.css
and looks like this:
.game-roomDescription {
min-height: 10em;
}
.game-messageLog {
min-height: 20em;
}
.game-characterDetails {
min-height: 10em;
}
.game-playerList {
}
Finally, we need to make it so that this component is rendered as needed. This is done in our main src/App.js
file, by making the following changes.
First, we need to depend on our new component. Add the following to the top, with the other require
statements:
import Game from './Game';
Next, we need some default State for the component to render correctly. This doesn’t actually need to contain anything, but simply to exist. Add the following to the component constructor:
this.state = {};
Finally, we need to update the render()
method to render either our Login or Game component as appropriate:
render() {
const { player } = this.state;
let appContents;
if (player) {
appContents = <Game player={ player } />;
} else {
appContents = <Login handleLogin={ this._handleLogin } />;
}
return (
<div className="App container-fluid">
{ appContents }
</div>
);
}
Note that we are rendering the Game component if, and only if, we have some player details, and when we do we pass these into the Game component for it to make use of.
Once all of this is done, we can create a character and the game will now look like this:
Display Current Character Details
Now that we have a Game display, let’s remind the player which character they are playing as. This will go in the top-right corner, and will simply repeat back what was selected on the first screen.
Firstly, create a new file called src/CharacterDetails.js
containing the following:
import React, { Component } from 'react';
import races from './races';
import classes from './classes';
class CharacterDetails extends Component {
render() {
const { player } = this.props;
const race = races.find((race) => race.id === player.race);
const cls = classes.find((cls) => cls.id === player.cls);
return (
<div>
<div className="row">
<div className="col-2">
<b>Name</b>
</div>
<div className="col-10">
{ player.name }
</div>
</div>
<div className="row">
<div className="col-2">
<b>Race</b>
</div>
<div className="col-10">
{ race.name }
</div>
</div>
<div className="row">
<div className="col-2">
<b>Class</b>
</div>
<div className="col-10">
{ cls.name }
</div>
</div>
</div>
);
}
}
export default CharacterDetails;
Then update src/Game.js
as follows:
// In the requires section at the top
import CharacterDetails from './CharacterDetails';
// In the render method
<div className="game-characterDetails">
<CharacterDetails player={ this.props.player } />
</div>
This simply causes us to render out the Name, Race and Class that was entered into the first screen, as follows:
Display Characters in Current Room
The final part that we want to achieve for now is to display a list of all the characters in the current room. This is directly derived from Pusher using the Presence Channel that we’ve previously discussed. To achieve this, every character will subscribe to the Presence Channel for the room they are currently in, and will use the data provided by the Presence Channel to list the other characters in the same room.
Again, this will require a new component that will render the characters present in the current room. It will also require us to subscribe to the appropriate Presence Channel on our Pusher connection and to manage some events from that channel to ensure that the list is kept correctly up-to-date.
Create a new file src/CharacterList.js
containing the following:
import React, { Component } from 'react';
import pusher from './pusher';
class CharacterList extends Component {
constructor(props) {
super(props);
this.state = {
players: []
};
}
componentDidMount() {
if (this.props.room) {
this._bindToChannel();
}
}
_bindToChannel() {
const channel = pusher.channel(`presence-room-${this.props.room}`);
channel.bind('pusher:subscription_succeeded', function() {
channel.bind('pusher:member_added', function() { this._updateMembers(channel); }.bind(this));
channel.bind('pusher:member_removed', function() { this._updateMembers(channel); }.bind(this));
this._updateMembers(channel);
}.bind(this));
}
_updateMembers(channel) {
this.setState({
players: Object.keys(channel.members.members)
.map(id => channel.members.members[id])
});
}
render() {
const players = this.state.players
.map((player) => (
<div>{ player.name }</div>
));
return (
<div>
<h5>Characters here</h5>
{ players }
</div>
);
}
}
export default CharacterList;
This is more complicated than our other components, in that it not only has stated but it has some React lifecycle hooks.
The initial state of the component is that there are zero players present. This will be changed whenever we handle a pusher:member_added
or a pusher:member_removed
event on our channel, indicating that a character has entered or left the room.
The lifecycle method componentDidMount
is called when the Character List is first rendered and is where we will be binding to the appropriate events on our channel. We are retrieving the channel itself by name from the Pusher connection – assuming that we have already subscribed to it – and handling the previously mentioned events when the subscription is successful.
Every time the character list changes, we update the names that we store in the state and cause a re-render, displaying the list of characters to the player.
Next, we need to update src/Game.js
to actually subscribe to the correct channel and display our new component:
// At the top of the file with the other Require statements
import CharacterList from './CharacterList';
// Add a constructor to manage the channel subscription
constructor(props) {
super(props);
this.state = {
room: 'start'
};
pusher.subscribe('presence-room-start');
}
// Update the render method to display our character list
<div className="game-playerList">
<CharacterList room={ this.state.room } />
</div>
The end result of this will be as follows:
Summary
Pusher Presence Channels are a fantastic way of keeping track of users that need to be grouped together – in this case in a virtual room in a virtual game world. They are very simple to use and give great flexibility in what you can do with them.
The full source code for this article is available from GitHub.
In the next article in this series, we will be expanding the game world to give it multiple rooms that can be visited, and allow the player to move between them and explore the world. As the player does this, the room description and the characters will dynamically update automatically, and the message log will show characters entering and leaving.
Go to Part 2: Exploring the World
December 20, 2017
Ready to begin?
Start building your realtime experience today.
From in-app chat to realtime graphs and location tracking, you can rely on Pusher to scale to million of users and trillions of messages