For my final bootcamp project, I decided to make use of websockets to create a 1v1 card game using React & Rails. In my research, I had difficulty finding sources that went beyond the functionality of a simple chat application. As a result, the guide below worked for my purposes but there may be more efficient ways to handle this type of connection.
Basic terminology
A consumer
is the client connecting to a websocket and is created in a javascript client. A consumer
can be subscribed to multiple channels at a time.
A channel
"Each channel encapsulates a logical unit of work, similar to what a controller does in a typical MVC setup" - rubyonrails.org
A subscriber
is a consumer
that has successfully connected to a channel
Getting Started
Starting with Rails, in the Gemfile, uncomment gem "rack-cors"
and run bundle install
in your terminal. Open the cors.rb file (app/config/initializers/cors.rb) and uncomment this section:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "*"
resource "*",
headers: :any,
methods: [:get, :post, :patch, :delete, :options, :head]
end
end
For the purposes of this guide, I am setting origins to "*". That is fine if you are working locally but be sure to update it to the URL of your front-end application if you deploy your project.
Next we need to create a route in the routes.rb file and mount the action cable server, your routes file should look like this:
Rails.application.routes.draw do
# rest of your routes
mount ActionCable.server => '/cable'
end
Open the cable.yml file, it should look like this:
development:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: <your_app_name>_production
This guide only focuses on the development environment.The async adapter will suffice however you will most likely need to use redis for production, please refer to the documentation your hosting service for configuration.
Now let's set up the React app to work with our server. First let's install an npm package that helps connect React with rails. There are many available but I found @rails/actioncable
to be the most up-to-date and most used.
In your console, run npm i @rails/actioncable
. Once installation is complete, create a new file cable.js
in the src
folder and add this code:
import { createConsumer } from "@rails/actioncable";
const consumer = createConsumer(`<your_backend_url>/cable`)
export default consumer
Creating a connection
Now that both applications are configured to use ActionCable we can create the websocket connection.
In the Rails app, create a new file in the channels folder app/channels
. The name of this file will be the name of the channel itself. If you used a rails generator you will see two methods added by default, subscribed
and unsubscribed
For my game, when a new game is started, a random key is generated. I am using that key to identify channel instances, my channel file looks like this:
class GameSessionChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
game = Game.find_by(game_key: params[:game_key])
stream_for game
end
def unsubscribe
# Any cleanup needed when channel is unsubscribed
puts "unsubscribed"
stop_all_streams
end
end
In React, the game creates the websocket connection when a user joins a game as part of a useEffect when the Game component first loads:
import consumer from "../cable"
useEffect(() => {
consumer.subscriptions.create({
channel: "GameSessionChannel",
game_key: `${gameSession.game_key}`,
},{
connected: () => {
console.log("connected")
setIsConnected(true)
},
disconnected: () => {
console.log("disconnected")
setIsConnected(false)
},
received: (data) => {
switch(data.action) {
case "attack-declared":
// any logic you want to run
break;
case "defense-declared":
// any logic you want to run
break;
// any other cases needed
}
}
})
return () => {
consumer.disconnect()
}
},[])
Let's break this down. First, consumer.subscriptions.create
will create the websocket connection. It needs a channel
and identifier
. The channel
must match the class of the Rails channel that will handle this connection, in this case it is "GameSessionChannel". The identifier
is the game_key
. In the Rails application, I use this game_key to find the Game in the database and set it as the broadcast target for this channel in the subscribe method.
connected
, disconnected
and received
are functions that will run when those conditions are met, i.e when the connection is created/terminated and when the client receives data from the server.
I also include a return
callback function to disconnect the websocket when the user navigates away from the page or closes the window.
Broadcasting data
Now that we have the connections set up correctly, we can broadcast data from anywhere within our Rails application.
Let's send a message when a player joins the game.
In our React app, we will send a fetch request to the server with the User information and the Game Key as parameters:
const handleSubmit = (e) => {
e.preventDefault()
fetch(`<your_backend_url>/joingame/${formData.gameKey}`,{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({opponent_id: currentUser.id}),
})
.then(res => {if (res.ok) {
res.json()
.then(existingGame => {
setGameSession(existingGame)
navigate(`/game/${existingGame.game_key}`)
})
} else {
res.json().then(errors => setErrors(errors))
}})
The Rails application will then look up the Game in the database, associate the user as the opponent(or host if this user is creating a game) then broadcast to all subscribers that a user has joined:
def join_game
game = Game.find_by!(game_key: params[:game_key])
user = User.find(params[:opponent_id])
game.update!(opponent_id: params[:opponent_id])
user.update!(gamesPlayed: user.gamesPlayed + 1)
GameSessionChannel.broadcast_to game, {action: "user-joined", game: game, message: "Opponent has joined the game"}
render json: game, status: :accepted
end
This line is the websocket broadcast from the game
channel as defined by the game_key
and is sent to all subscribers of that particular game:
GameSessionChannel.broadcast_to game, {action: "user-joined", game: game, message: "Opponent has joined the game"}
The action
key matches a case in the React app, which then triggers the state of the gameSession to be updated with the new player's information as well as adding a message
to the game log for all players to see.
case "user-joined":
setGameSession(data.game)
setGameLog(gameLog => ([ ...gameLog, data.message]))
break;
When I was first setting up broadcasts, I had set all of my cases to console.log(data.message) as an easy test to check if data was coming from the server correctly.
Additional reading:
Rails docs
@rails/actioncable npm package
Top comments (0)