Online Text RPG and Interacting With the World (Part 3)

online-text-rpg-react-interacting-with-world-part-3-header.png

A tutorial showing how to build an online text RPG with React and GraphQL. Part 3 deals with interacting with the world.

Introduction

This tutorial is part of

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:

Demo of app

Communicating with other players

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:

screenshot of 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.

use commands

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:

demo commands fully working

Opening and closing doors

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:

demo doors

Conclusion

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.