A few days ago, I set out with a coding partner to build a multiplayer game with React. The game will essentially be a digital version of Shobu, the popular and simple strategy board game. Central to the concept of our web app, a player can visit our site, generate a new game and send a link for the newly generated game to a friend. The friends could then play as opponents, each with a separate view of the game (and able to move only their own pieces). They could leave in the middle of a game, close their browsers, and reopen their game links from another device/browser and still have their game progress reflected.
The unique game links felt like a better solution for the app than requiring players to log in. No player information (other than a first name or alias if the user opted to type one in rather than defaulting to "Player 1" or "Player 2") would be stored in the database, and the backend model relationships provided for the player model belonging to the game model. Thus, once the game was over and deleted from the database, so too would be the players. In essence, each new game that a user would start would create an entirely new player instance, with a new unique game link.
I'd done work on previous projects with a Rails backend and handled authentication with a pretty standard password encryption approach. Now, I wanted to use some of those same tools, but try something new in the process that felt a little more appropriate for this type of project. So, how to achieve this?
Step # 1 Setting up the Rails Backend
We're using Rails as our backend API for this project, so I first needed to properly set up my API end points in my routes.rb
file and generate a controller that could make use of the parameters I would be feeding in (namely, the dynamic portion of the game urls we'd ultimately be generating).
# in ./config/routes.rb
Rails.application.routes.draw do
get '/players' => "players#index"
end
Rather than relying on typical RESTful routing conventions and use the #show
route by including the player id in the URL (ie) http://domain-name.com/players/1), I decided to use JSON web tokens (JWTs) to send an encoded version of the unique player id to the rails back end. It seemed like a nice and easy way to encode the information I wanted to send and solve a couple of basic issues in the process:
Prevent players from accessing a game link other than their own (either accidentally or maliciously). If we stuck with accessing the API endpoint via the player id (which are assigned by the database sequentially), a user could easily guess at other player's URLs and sign into their game link, or even arrive at somebody else's URL by mistyping or omitting part of their URL. Using the JWT in the URL instead of an unencoded id makes it very unlikely that a user would wind up at a game that wasn't their own. Because of the way that JWTs work (utilizing a signature at the end to validate the data, and a secret key stored on the backend), it makes it exceedingly difficult to stumble upon another valid URL by chance.
Ensure that the game link is unique. There are certainly plenty of other methods to generate links, but this was the easiest way that I could think of to generate a link that was certain to be unique to the player in addition to being nearly impossible to guess. If we instead relied upon a concatenation of random words or random characters, we would either need to be comfortable with the (extremely unlikely) chance that two players would be assigned the same link, or take the extra steps to ensure that a collision never occurred (ie) potentially scanning through an entire database of used URLS each time a new link was created. As truly unlikely as a collision would be, the larger our user base, the more likely it becomes and the more unwieldy a possible used URL lookup table would be. I have actually run into colliding URLS on services like Surge.sh, so it's definitely NOT unthinkable.
Below is my code for encoding and decoding the JWT. The unique JWT "url" is assigned when a new player is created (as shown in player.rb
, and that player can be easily retrieved from the database when the frontend sends that JWT back (as demonstrated in players_controller.rb
). Please note that you will first need to include gem "jwt"
in your Gemfile
and run bundle install
in the terminal.
# in ./app/models/player.rb
class Player < ApplicationRecord
belongs_to :game
after_create :generate_url
def generate_url
self.update_attributes(url: JWT.encode(self.id.to_s, "some_secret_string"))
end
end
# in ./app/controllers/players_controller.rb
class PlayersController < ApplicationController
def index
player_id = JWT.decode(params[:jwt], "some_secret_string")[0].to_i
player = Player.find(player_id).game
render json: player.game
end
end
Step # 2 Setting up Client Side Routing in React
After we get our backend successfully creating and processing the JWT as parameters in a GET request (I'd highly recommend a tool like Postman for testing the API), we can move on to our frontend. Since we built our game as a single page application in React, I'm going to demonstrate how to do this with client side routing via React-Router.
Since we handle a lot of the API calls for the game in our top App level, it seemed the path of least resistance was to define our client side routes one level higher, in index.js
. We weren't envisioning multiple different looking pages in our app; just one basic page that presents slightly differently depending on whether or not there is an active game. Our routing is handling 4 basic scenarios:
1) Basic route ('/') - This route will render the page with a blank game board, and render a "Play a New Game" button. Once the button is clicked, we'll redirect to the primary player's game link. This route will cause our App
component to render without any props and thus will not trigger initial API fetch request.
2) Valid game in progress in progress ('/gameinplay/some-indecipherable-jwt-that-decodes-to-a-valid-player-id') - This route will render the page with the current board of an ongoing game. Our router will render our App
element and pass in the jwt as props. Because the jwt is present in the props, our App component will know to fetch the game details from the backend API discussed in the previous section. The view will be slightly different depending on which of the two game's players the link belongs to.
3) Invalid game link ('/gameinplay/any-other-string-that-is-not-a-jwt-that-decodes-to-a-valid-player-id') - This will be handled by our client-side router in the same exact manner as the valid link discussed in the previous scenario, however, the fetch request will return an error (since the jwt will not evaluate to a valid player id). We built some error handling to redirect the user to the root directory.
4) Any other invalid URL ('/any-other-string') - At the time of writing this blog entry, I haven't yet accounted for this scenario. However, I intend this to redirect to the root directory ('/') where the user can create a new game.
Note: You'll first need to run npm install react-router-dom
in the terminal.
// in ./index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter as Router, Route } from 'react-router-dom'
ReactDOM.render(
<React.StrictMode>
<Router>
<div>
// scenario #1, renders App with no props
<Route exact path="/" render={() => <App />}/>
// scenario #2 and #3, renders App and passes in the jwt as props
<Route path="/gameinplay/:jwt" render={(routerProps) => <App jwt={routerProps.match.params.jwt}/>}/>
</div>
</Router>
</React.StrictMode>,
document.getElementById('root')
);
// in ./App.js
import React from 'react';
const playersURL = "http://localhost:3000/players"
class App extends React.Component {
fetchOngoingGame = () => {
fetch(`${playersURL}?jwt=${this.props.jwt}`)
.then(resp => {
if (resp.ok) {
return resp.json()
}
else {throw new Error('Not a valid game link')}
})
// handling scenario #2 by populating the currentGame in state
.then(currentGame => {
this.pieces = currentGame.pieces
this.setState({ currentGame: currentGame.game })
})
// handling scenario #3 with a redirect
.catch(() => {
window.history.pushState({pathname: '/'}, "", '/')
})
}
// our initial fetch runs when the App component first mounts if there are props present
componentDidMount() {
if (this.props.jwt)
this.fetchOngoingGame()
else {console.log("This will load a new game")}
}
Conclusion
We now have both pieces (frontend and backend API) up and running and connected. This seems a relatively straightforward approach to user authentication when security and user data is not a concern, as in the case of our simple web game. This is the first time I've attempted this approach, so I'd be very interested to hear some feedback from others who have attempted similar things and see how they approached the same problem. Thanks for reading!
Top comments (0)