A tutorial showing how to build an online text RPG with React and GraphQL. Part 3 deals with interacting with the world.
In the part 2 of this tutorial series, we added the ability to see and explore the world around us. The only problem was that we were strictly a viewer, and had no ability to interact with it in any way. This meant, for example, that if a door was shut we couldn’t open it to get any further.
This article is going to change this, giving us the ability to open and close doors, and to chat with other players in the same world. At the same time, we are going to gain the ability to see changes that other people make to the world around us, making everything feel like an immersive experience.
The end result will be something like this:
We are going to add two different forms of communication with other players – speaking and shouting. Speaking will be heard by anyone in the same room, whilst shouting will be heard by anyone in the entire game world.
Actual RPGs will give a huge variety more choice than this, including whispering to specific players, talker channels for guild chat, and emotes to express actions. This is beyond the scope of this article, but the concepts are all the same.
In order to support this, we are going to add two new mutations to our GraphQL server – speak
and shout
.
Firstly, we need to be able to broadcast messages to Pusher from our GraphQL API. Update pusher.js
in the backend project to change the exports as follows:
1module.exports = { 2 router: router, 3 pusher: pusher 4 };
And then update the app.js
in the backend so that the imports are:
1var pusher = require('./routes/pusher').router;
Now, we can make use of this in our GraphQL API. Next, we need to add a couple of mutations:
1speak(room: ID!, sessionId: ID!, message: String!): String 2 shout(room: ID!, sessionId: ID!, message: String!): String
In both cases, we are taking the ID of the room that the player is in, and of the player that is speaking/shouting. We also take the message that they are saying. The GraphQL return value is a simple String, purely because we’ve got to return something.
Next, implement these mutations in our Mutation
resolver:
1speak: (_, { room, sessionId, message }) => { 2 pusher.trigger('presence-room-' + room, 'speak', { 3 user: users.getUser(sessionId), 4 message: message 5 }, sessionId); 6 return "Success"; 7 }, 8 shout: (_, { room, sessionId, message }) => { 9 pusher.trigger('global-events', 'shout', { 10 room: room, 11 user: users.getUser(sessionId), 12 message: message 13 }, sessionId); 14 return "Success"; 15 }
Note that we provide the sessionId
as the third parameter to these calls. That simply ensures that the user that triggered the events doesn’t also receive a copy of them – since that will be handled differently.
And finally, we need a new import for the Pusher client. This is simply:
1const pusher = require('./pusher').pusher;
Once all of this is done, we can start the server up and use our GraphiQL interface to test it. You will need a valid Session ID which can be captured from the Developer Tools in your browser by logging in to the Frontend, and you can see the broadcast messages using the Pusher Debug Console:
We now need to be able to make use of this functionality. This means that we need to trigger these from our UI, and then be able to display messages received in response from them.
Firstly, let’s refactor our ability to add messages to the message log so that we can re-use it. Let’s add a new method to Game.js
:
1_addMessage(message) { 2 const messages = this.state.messages; 3 messages.unshift({ 4 timestamp: new Date(), 5 message: message 6 }); 7 this.setState({ 8 messages 9 }); 10 }
Then we can make use of it, initially from failing to exit a room – which we did in the last article. Update _onExitRoom
as follows:
1if (result.data.exitRoom.reason === 'DOOR_CLOSED') { 2 this._addMessage("That door is closed!"); 3 }
Next, we’ll add in some command handling. This is using the text input that we created right at the start. Firstly, we need to update that control to be able to submit commands. This is easiest done by wrapping it in an HTML form – they automatically submit on pressing Enter – and handle the form submission. Update the render
method as follows:
1<form onSubmit={this._handleCommand}> 2 <input type="text" className="form-control" placeholder="Enter command" ref={(input) => { this.commandInput = input; }} /> 3 </form>
To do this more correctly would involve creating a new React component to represent the command input. However, for the purposes of this tutorial, the above is adequate.
The use of the ref
parameter makes it possible to refer to our input field, which we will see soon.
We now need to be able to actually handle the commands. This is done by adding a new method and then binding it to the correct context. First, add the new method:
1_onCommand(e) { 2 e.preventDefault(); 3 const command = this.commandInput.value; 4 this.commandInput.value = ""; 5 if (command.startsWith("/shout ")) { 6 const shout = command.substring(7); 7 this._addMessage(`You shout "${shout}"`); 8 } else { 9 this._addMessage(`You say "${command}"`); 10 } 11 }
This makes use of our _addMessage
method that we’ve just factored out. It checks if the input command starts with the string “/shout”, and either shouts what follows that or else says the entire string.
Finally, we need to bind this to the correct context. This is done by adding the following line to our constructor:
1this._handleCommand = this._onCommand.bind(this);
At this point, we can use these commands successfully.
However, this doesn’t yet let anyone else hear us. For that, we need to trigger our mutations and then handle the events from Pusher when they are received.
Firstly, we need to define the mutations that we’re going to call. Add these to the top of Game.js
:
1const SPEAK_MUTATION = gql`mutation($room:ID!, $session:ID!, $message: String!) { 2 speak(room: $room, sessionId: $session, message: $message) 3 }` 4 const SHOUT_MUTATION = gql`mutation($room:ID!, $session:ID!, $message: String!) { 5 shout(room: $room, sessionId: $session, message: $message) 6 }`
Then we need to trigger these. Update the _onCommand
method as follows:
1_onCommand(e) { 2 e.preventDefault(); 3 const { room } = this.state; 4 const command = this.commandInput.value; 5 this.commandInput.value = ""; 6 if (command.startsWith("/shout ")) { 7 const shout = command.substring(7); 8 graphqlClient.mutate({ 9 mutation: SHOUT_MUTATION, 10 variables: { 11 room: room, 12 session: pusher.connection.socket_id, 13 message: shout 14 } 15 }).then((result) => { 16 this._addMessage(`You shout "${shout}"`); 17 }); 18 } else { 19 graphqlClient.mutate({ 20 mutation: SPEAK_MUTATION, 21 variables: { 22 room: room, 23 session: pusher.connection.socket_id, 24 message: command 25 } 26 }).then((result) => { 27 this._addMessage(`You say "${command}"`); 28 }); 29 } 30 }
This will call the appropriate GraphQL mutation based on our command, and then add the message to our message log in response to it returning. This way, we get to add the output at the same time as other players hear it. If, at this point, you try speaking or shouting then you can see the events appear in the Pusher Dashboard.
Add the following new methods to Game.js
:
1_receiveShout(data) { 2 const { room } = this.state; 3 if (room === data.room) { 4 this._addMessage(`${data.user.name} shouts "${data.message}"`); 5 } else { 6 this._addMessage(`Somebody shouts "${data.message}"`); 7 } 8 } 9 _receiveSpeak(data) { 10 this._addMessage(`${data.user.name} says "${data.message}"`); 11 }
These are used to handle when we receive an event indicating a shout or a speak. Now we simply need to bind to the appropriate events. Update the constructor
as follows:
1const channel = pusher.subscribe('presence-room-start'); 2 channel.bind('pusher:subscription_succeeded', function() { 3 channel.bind('speak', function(data) { this._receiveSpeak(data); }.bind(this)); 4 }.bind(this)); 5 6 const globalChannel = pusher.subscribe('global-events'); 7 globalChannel.bind('pusher:subscription_succeeded', function() { 8 globalChannel.bind('shout', function(data) { this._receiveShout(data); }.bind(this)); 9 }.bind(this));
And then update the _onExitRoom
function:
1const channel = pusher.subscribe(`presence-room-${roomName}`); 2 channel.bind('pusher:subscription_succeeded', function() { 3 channel.bind('speak', function(data) { this._receiveSpeak(data); }.bind(this)); 4 }.bind(this));
At this point, we can hear speaking from anyone in the same room and shouts from anyone in the entire game. If the shouter is in the same room, we can see who it was, but if not then we just get a vague description:
The next thing we want to be able to do is to interact with the world in some manner. We are going to implement this by allowing characters to open and close doors, and to see when the doors are opened and closed by others.
We already have mutations available to actually open and close the doors. We just don’t have them hooked up to the UI in any way at present.
Firstly, let’s display the actions in the UI. Update RoomDescription.js
so that the render
method in the RoomDescription
class has the following:
1const exits = room.exits.map((exit) => { 2 let doorAction; 3 if (exit.door.state === "closed") { 4 doorAction = ( 5 <span>(<a href="#" onClick={(e) => this._openDoor(e, exit)}>Open</a>)</span> 6 ); 7 } else if (exit.door.state === "open") { 8 doorAction = ( 9 <span>(<a href="#" onClick={(e) => this._closeDoor(e, exit)}>Close</a>)</span> 10 ); 11 } 12 return ( 13 <li className="list-inline-item"> 14 <a href="#" onClick={(e) => this._handleExitRoom(e, exit)}> 15 {exit.description} 16 </a> 17 { doorAction } 18 </li> 19 ); 20 });
Then we need to trigger the mutations. Add the following to the top of RoomDescription.js
:
1const OPEN_DOOR_MUTATION = gql`mutation($room:ID!, $door:ID!) { 2 openDoor(room:$room, door:$door) { 3 state 4 } 5 }` 6 const CLOSE_DOOR_MUTATION = gql`mutation($room:ID!, $door:ID!) { 7 closeDoor(room:$room, door:$door) { 8 state 9 } 10 }`
And define our _openDoor
and _closeDoor
methods in the RoomDescription
class:
1_openDoor(e, exit) { 2 e.preventDefault(); 3 this.props.openDoorHandler(exit.name); 4 } 5 _closeDoor(e, exit) { 6 e.preventDefault(); 7 this.props.closeDoorHandler(exit.name); 8 }
Finally, we need to provide these two handlers. They come from the RoomDescriptionWrapper
class, as follows:
1_onOpenDoor(door) { 2 const { room } = this.props; 3 graphqlClient.mutate({ 4 mutation: OPEN_DOOR_MUTATION, 5 variables: { 6 room: room, 7 door: door 8 } 9 }).then(() => { 10 this._getRoomDetails(room); 11 }); 12 } 13 _onCloseDoor(door) { 14 const { room } = this.props; 15 graphqlClient.mutate({ 16 mutation: CLOSE_DOOR_MUTATION, 17 variables: { 18 room: room, 19 door: door 20 } 21 }).then(() => { 22 this._getRoomDetails(room); 23 }); 24 }
And get wired in by updating the render
method in the RoomDescriptionWrapper
as follows:
1return <RoomDescription room={ room } 2 exitRoomHandler={this.props.exitRoomHandler} 3 openDoorHandler={this._onOpenDoor.bind(this)} 4 closeDoorHandler={this._onCloseDoor.bind(this)} />
This will work, but you won’t see anything happen. This is because the Apollo GraphQL Client that we are using automatically caches the response from queries, so if the exact same query is made then the network requests aren’t necessary. We need to clear this cache when fetching room data so that we get the fresh state of the room. This is done by updating the _getRoomDetails
method to add the following right at the top:
1graphqlClient.resetStore();
Now, we can go and open and close doors as much as we wish.
The only problem is that we won’t see when somebody else opens or closes a door without leaving and re-entering the room. Let’s fix that again by using Pusher to send updates whenever the door state is changed. This is a subtly different case to above because we need to notify the room on both sides of the door that the door has changed, since they are both affected.
In routes/graphql.js
in the backend project, add a new method to broadcast an indication that the state of the door has changed:
1function broadcastRoomUpdatesForDoor(doorName) { 2 const targetRoomName = Object.keys(worldData.rooms) 3 .filter(roomName => { 4 const roomDetails = worldData.rooms[roomName]; 5 const hasDoor = Object.keys(roomDetails.exits) 6 .map(exitName => roomDetails.exits[exitName]) 7 .map(exitDetails => exitDetails.door) 8 .includes(doorName); 9 return hasDoor; 10 }) 11 .forEach(roomName => { 12 pusher.trigger('presence-room-' + roomName, 'updated', {}); 13 }); 14 }
This iterates over every room in our World data finds each room that has the named door as an exit, and then sends a Pusher event on the Presence channel for that room to indicate that the room has been updated. We don’t need a payload because our event details are simply that the room that this channel is for has been updated in some way.
We then simply need to call this from the openDoor
and closeDoor
handlers:
1broadcastRoomUpdatesForDoor(doorName);
Now, all we need to do is update the UI every time we receive one of these events.
Add the following import to RoomDescription.js
in the frontend:
1import pusher from './pusher';
Then add the following method to the RoomDescriptionWrapper
class:
1_bindToUpdates(room) { 2 const channel = pusher.channel(`presence-room-${room}`); 3 channel.bind('pusher:subscription_succeeded', function() { 4 channel.bind('updated', function() { this._getRoomDetails(room); }.bind(this)); 5 }.bind(this)); 6 }
We never need to worry about unbinding this event because the entire channel subscription is removed when we change rooms.
Next, call this from both the componentDidMount
and componentWillReceiveProps
functions:
1this._bindToUpdates(room);
And now we have the ability to see whenever the state of any doors in the current room change, regardless of who caused the change to happen.
The end result of all this is something like the following:
Throughout this series, we have built a small online world, complete with multiple characters able to explore and interact with it, and able to communicate with each other. This is the very beginning of your very own online roleplaying game, and it doesn’t take a lot of imagination to see how this can be expanded to make something much more enticing for players.
As before, the full source code for this can be found on GitHub. Why not try expanding the world? Adding NPCs in to interact with, or combat, or items, or a whole vast array of other commonly found RPG elements. Or, instead, think up something new and exciting to do instead. We look forward to whatever you come up with.