Rock Paper Scissors with SignalR Core

  1. Introduction
  2. Create the App
  3. Build Data Layer
  4. Implement Game Logic
  5. SignalR Server Setup
  6. SignalR Client Setup
  7. Verify SignalR Connection
  8. Starting the Game (Server)
  9. Starting the Game (Client)
  10. Verify Game Creation
  11. Completing the Game (Server)
  12. Completing the Game (Client)
  13. Test the Game

1. Introduction

SignalR Core allows us to build modern real-time applications without having to worry about the lower level details of protocols and browser compatibility. We are going to use it to build a rock paper scissors game.

The finished version of the code can be found in this repository

2. Create the App

To start, let’s create a new .NET Core 3.1 Web application with the MVC template. You can do this through Visual Studio or the CLI. Here are the commands to create it from the CLI:

dotnet new sln -n RockPaperScissors
dotnet new mvc -n "RockPaperScissors"
dotnet sln add RockPaperScissors/RockPaperScissors.csproj

3. Build Data Layer

We will build a data layer based on the EF Core In-Memory provider.

First, we need some Nuget packages. We can install them with the following commands in the Nuget Package Manager Console:

Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.InMemory

We’ll create a folder called Data at the top-level of our Visual Studio project which will house our domain models and DbContext class.

We will need to define some enums in order to represent the game state. Create a file called Play.cs with the following code:

public enum Play
{
    Rock,
    Paper,
    Scissors
}

We’ll need one more enum to represent the outcome of a game. Create a file called GameResult.cs with the following content:

public enum GameResult
{
    Win,
    Draw,
    Lose
}

We can now add the domain models. We will define a Game object which owns two different GameSession objects. Each GameSession object represents the behaviour of a different player within the game. Let’s add these domain models to a file called Game.cs:

public class Game
{
    public Guid Id { get; set; }
    public GameSession Player1 { get; set; }
    public GameSession Player2 { get; set; }

}
[Owned]
public class GameSession
{
    public string Id { get; set; }
    public Play? Play { get; set; }
}

Our DbContext definition is very simple. We’ll add it to a file called GameDbContext.cs:

public class GameDbContext : DbContext
{
    public GameDbContext(DbContextOptions<GameDbContext> options) : base(options) { }
    public DbSet<Game> Games { get; set; }
}

We must register the GameDbContext with the dependency injection container by adding the following lines to the ConfigureServices method of the Startup.cs file:

services.AddDbContext<GameDbContext>(o =>
{
    o.UseInMemoryDatabase("Game");
});

4. Implement Game Logic

In this section we’ll define a service which determines the game outcome based on the player’s choices. We’ll also define some important constants.

The life cycle of the game will consist of several distinct events which will determine the communication between client and server. Let’s create a centralized place where we can define these events. Create a folder called Constants at the top-level of the project and add the following code in a file called GameEventNames.cs

public class GameEventNames
{
    //Player has created a game and must wait for opponent to join
    public const string WaitingForPlayerToJoin = "WaitingForPlayerToJoin";

    //Both players have joined game
    public const string GameStart = "GameStart";

    //Player has chosen rock,paper or scissors but the opponent has not. Player must wait for opponent to make a choice
    public const string WaitingForPlayerToPlay = "WaitingForPlayerToPlay";

    //Both Players have made their choice and a game result (win/draw/lose) is sent back to both players
    public const string GameEnd = "GameEnd";
}

Now we’ll create a service which will help us to determine the outcome of a game. It works by creating a list of tuples which represent all possible game results and querying that list with the appropriate parameters to get the game result.

Let’s create a folder called Services at the top-level of the project. We’ll add a file to it called GameService.cs which will contain the following code:

public interface IGameService
{
    GameResult GetGameResult(Play playerChoice, Play opponentChoice);
}

public class GameService : IGameService
{
    public GameResult GetGameResult(Play playerChoice, Play opponentChoice)
    {
        return _possibleGameStates
            .Single(gs => gs.player == playerChoice &amp;&amp; gs.opponent == opponentChoice)
            .result;
    }

    private readonly List<(Play player, Play opponent, GameResult result)> _possibleGameStates =
        new List<(Play player, Play opponent, GameResult result)>
        {
            (Play.Rock, Play.Rock, GameResult.Draw),
            (Play.Rock, Play.Paper, GameResult.Lose),
            (Play.Rock, Play.Scissors, GameResult.Win),
            (Play.Paper, Play.Rock, GameResult.Win),
            (Play.Paper, Play.Paper, GameResult.Draw),
            (Play.Paper, Play.Scissors, GameResult.Lose),
            (Play.Scissors, Play.Rock, GameResult.Lose),
            (Play.Scissors, Play.Paper, GameResult.Win),
            (Play.Scissors, Play.Scissors, GameResult.Draw),
        };
}

We have to register this service with the DI container as well. We can do so by adding the following line to the ConfigureServices method in the Startup.cs file:

services.AddSingleton<IGameService, GameService>();

5. SignalR Server Setup

SignalR Core is built around the concept of hubs. We are now going to define our own hub. Our initial definition of the hub will be very simple, but it will still allow us to demonstrate the communication between the client and the server.

Let’s start by creating a folder called Hubs. Add a file to this folder called GameHub.cs with the following content:

public class GameHub : Hub
{
    public GameHub()
    {
    }
}

We will need some more configuration to get our GameHub working. Add the following line to the ConfigureServices method in the Startup.cs file:

services.AddSignalR();

We will also need to replace the following block in the Configure method:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
});

with the following code which will configure the route for our GameHub:

app.UseEndpoints(endpoints =>
{
    endpoints.MapHub<GameHub>("/gamehub");
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
});

6. SignalR Client Setup

In order to work with the JavaScript SignalR Core client library, we’ll first need to import it. Right-click on your project in Visual Studio and click Manage Client-Side Libraries. This should bring up a libman.json file. Replace the contents of this file with the following JSON:

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [

    {
      "library": "aspnet-signalr@1.1.4",
      "destination": "wwwroot/lib/signalr"
    }
  ]
}

Next, we will need to replace the entire contents of the entire Home Index view. This file can be found at the path Views/Home/Index.cshtml. Replace the contents with the following Razor markup:

@{
    ViewData["Title"] = "Rock Paper Scissors";
}
<h1>Rock Paper Scissors!</h1>

@section Scripts{
    <!--Reference JavaScript SignalR client library-->
    <script src="~/lib/signalr/signalr.js"></script>

    <script>
        //the setupSignalR function will create a connection to the GameHub
        var setupSignalR = function () {
            //configure connection by specifying the route of the hub ("/gameHub")
            var connection = new signalR.HubConnectionBuilder()
                .withUrl("/gamehub")
                .build();
            //Start the connection to the hub
            connection.start()
                .then(() => alert("Connection Started"));
            return connection;
        };
        var connection = setupSignalR();
    </script>
}

7. Verify SignalR Connection

When we run our app from Visual Studio, we should now receive an alert in the browser which says Connection Started. This means that our client has successfully created a connection to the GameHub.

8. Starting the Game (Server)

The first step in the game is for a player to either join an existing game or create a new one. To do this we will need to add a method to our GameHub which our JavaScript client can access.

To keep our GameHub class clean, we will create a separate service for data-related tasks. Add a file called GameDataService.cs to the Services folder with the following contents:

public interface IGameDataService
{
    //Find a game which has a Player1Session but no Player2Session
    Task<Game> FindGameWaitingForPlayer();

    //Create a new game and add a PlayerSession with the provided id to it
    Task<Game> CreateGameWithPlayer(string playerSessionId);

    //Set Player2Session of the provided game using the provided playerSessionId
    Task<Game> AddPlayerToGame(Game game, string playerSessionId);
}
public class GameDataService : IGameDataService
{
    private readonly GameDbContext _gameDbContext;

    public GameDataService(GameDbContext gameDbContext)
    {
        _gameDbContext = gameDbContext;
    }

    public async Task<Game> FindGameWaitingForPlayer()
    {
        return await _gameDbContext.Games
            .Include(g => g.Player1)
            .Include(g => g.Player2)
            .FirstOrDefaultAsync(g => g.Player2 == null);
    }

    public async Task<Game> CreateGameWithPlayer(string playerSessionId)
    {
        var game = new Game
        {
            Id = Guid.NewGuid(),
            Player1 = new GameSession
            {
                Id = playerSessionId
            }
        };
        _gameDbContext.Games.Add(game);
        await _gameDbContext.SaveChangesAsync();
        return game;
    }

    public async Task<Game> AddPlayerToGame(Game game, string playerSessionId)
    {
        _gameDbContext.Attach(game);
        game.Player2 = new GameSession
        {
            Id = playerSessionId
        };
        await _gameDbContext.SaveChangesAsync();
        return game;
    }

For now this service has three methods:

  • FindGameWaitingForPlayer: searches for an existing game which still needs a player to start
  • CreateGameWithPlayer: creates a new game and sets the user as the first player in the game
  • AddPlayerToGame: sets the player as the second player in an existing game.

We now need to register this method with the DI Container by adding the following line to the ConfigureServices method of the Startup.cs file:

services.AddScoped<IGameDataService, GameDataService>();

We are finally ready to start modifying our GameHub. First let’s inject the GameDataService we just created into the constructor for GameHub:

private readonly IGameDataService _gameDataService;
public GameHub(IGameDataService gameDataService)
{
    _gameDataService = gameDataService;
}

Finally, let’s add a method to our GameHub which will allow the user to join a game:

public async Task JoinGame()
{
    //Try to find a game which is waiting for a player to join
    var existingGame = await _gameDataService.FindGameWaitingForPlayer();

    if (existingGame == null)
    {
        //If we couldn't find a game, create a new one
        await _gameDataService.CreateGameWithPlayer(Context.ConnectionId);
        //Then notify the player that they are waiting for another player to join
        await Clients.Client(Context.ConnectionId).SendAsync(GameEventNames.WaitingForPlayerToJoin);
    }
    else
    {
        //If we found one, the player will join that one
        await _gameDataService.AddPlayerToGame(existingGame, Context.ConnectionId);

        //Then Notify both players that the game has begun
        await Clients.Client(existingGame.Player1.Id).SendAsync(GameEventNames.GameStart);
        await Clients.Client(existingGame.Player2.Id).SendAsync(GameEventNames.GameStart);
    }
}

The above method will first try to find an existing game which requires another player to start. If such a game is found, the user is added to the game as the second player. In this case both players will be notified with the GameStart event.

However, if no existing game is found, a new Game object is created with the user set as the first player. The user is notified with the WaitingForPlayerToJoin event, which tells them they have to wait for another player to join the game before it can begin.

9. Starting the Game (Client)

We will now adjust our client according to the modifications made on the server. The client must call the JoinGame method on the GameHub. In addition to this, the client must also respond to the events GameStart and WaitingForPlayerToJoin.

In order to make it obvious that the client is responding to the events. We will need to add some more functionality to the view. We will add two new rows. One will display the current game status and the other will give the user their play options once the game starts.

Add the following markup just above the Scripts section of the Home Index view:

<!--This row displays the current status for the game-->
<div class="row">
    <div class="col-3">
        <label>Game Status</label>
    </div>
    <div class="col-3">
        <!-- The "gameStatus" span displays the actual text for the status of the game-->
        <span id="gameStatus">Looking For Game</span>
    </div>
</div>
<!--This row gives allows the player to select rock, paper or scissors and submit. It is hidden until the game starts-->
<div class="row" id="playRow" style="display:none">
    <div class="col-3">
        <label>Select Play</label>
    </div>
    <div class="col-3">
        <!--This is the dropdown which allows the user to select rock , paper or scissors-->
        <select class="form-control" id="playSelect">
            @foreach (var play in Enum.GetValues(typeof(RockPaperScissors.Data.Play)))
            {
                <option value=@play>@play</option>
            }
        </select>
    </div>
    <div class="col-3">
        <!--This button allows the player to submit their choice-->
        <button id="playButton">Play</button>
    </div>
</div>

Now we need to make the client call the JoinGame of the GameHub. To do this simply replace the following code in the Home Index view:

() => alert("Connection Started")

with the following code:

connection.invoke('joinGame')

Next we have to subscribe to the new events. We can do this by adding the following JavaScript at the end of the Scripts section of the Home Index view:

connection.on("@RockPaperScissors.Constants.GameEventNames.WaitingForPlayerToJoin",
    () => {$("#gameStatus").html("Waiting for another player to join.")})
connection.on("@RockPaperScissors.Constants.GameEventNames.GameStart",
    () => {
        $("#gameStatus").html("In Progress");
        $("#playRow").show();
    })

10. Verify Game Creation

When we run our app from Visual Studio, we should see the following:

  • The game status will display as Waiting for another player to join
  • When we open a second browser tab and paste the app URL, the status should be In Progress
  • The dropdown with the Rock, Paper and Scissors options should also be visible
  • When we switch back to the original browser tab, we should see the status as In Progress

11. Completing the Game (Server)

To complete the game, we will need to add another method to the GameHub which will allow the player to select Rock, Paper or Scissors. This new method can then return the game result to the client if both players in the game have made their choice.

To make the implementation of the GameHub method easier we will implement some extension methods for the Game class and additional methods in the GameDataService.

Let’s create a folder called Extensions in the Visual Studio project. In that folder, let’s add a file called GameExtensions.cs which will have the following content:

public static class GameExtensions
{
    public static GameSession GetOpponentSession(this Game game, GameSession player)
    {
        if (game.Player1.Id == player.Id)
        {
            return game.Player2;
        }
        else
        {
            return game.Player1;
        }
    }
    public static GameSession GetPlayerSession(this Game game, string playerSessionId)
    {
        if (game.Player1.Id == playerSessionId)
        {
            return game.Player1;
        }
        else
        {
            return game.Player2;
        }
    }
}

This extensions class has two simple methods. One will return the opposing player’s session. The other will retrieve the player’s session from the game using the session id.

Next we have to add some methods to the GameDataService. Let’s start by adding the following method signatures to IGameDataService:

//Find the game which the player is a part of 
Task<Game> FindGameBySessionId(string playerSessionId);

//Updates what choice the player made (Rock, Paper or Scissors) during the game
Task<Game> UpdatePlayForPlayer(Game game, string playerSessionId, Play play);

Now let’s add the corresponding implementations to GameDataService:

public async Task<Game> FindGameBySessionId(string playerSessionId)
{
    using var gameDbContext = _gameDbContextFactory.Create();
    return await gameDbContext.Games
        .Include(g => g.Player1)
        .Include(g => g.Player2)
        .FirstAsync(g =>
            g.Player1.Id == playerSessionId || g.Player2.Id == playerSessionId);
}
public async Task<Game> UpdatePlayForPlayer(Game game, string playerSessionId, Play play)
{
    using var gameDbContext = _gameDbContextFactory.Create();
    gameDbContext.Attach(game);
    var player = game.GetPlayerSession(playerSessionId);
    player.Play = play;
    await gameDbContext.SaveChangesAsync();
    return game;
}

Before we add the necessary method to the GameHub, we need to inject the GameDataService into it like this:

private readonly IGameDataService _gameDataService;
private readonly IGameService _gameService;
public GameHub(IGameDataService gameDataService, IGameService gameService)
{
    _gameDataService = gameDataService;
    _gameService = gameService;
}

Now we can add the following method to GameHub, which will allow the players to complete the game:

public async Task SelectPlay(string play)
{
    //find the game which the player is playing
    var game = await _gameDataService.FindGameBySessionId(Context.ConnectionId);
    //update the player session with the choice the player made
    await _gameDataService.UpdatePlayForPlayer(game, Context.ConnectionId, (Play)Enum.Parse(typeof(Play), play));

    var playerSession = game.GetPlayerSession(Context.ConnectionId);
    var opponentSession = game.GetOpponentSession(playerSession);

    if (opponentSession.Play.HasValue)
    {
        //If the opposing player has already made their play, we are ready to compute the game results and notify both players
        //Compute game result for current player
        var playerResult = _gameService.GetGameResult(
            playerSession.Play.Value,
            opponentSession.Play.Value).ToString();
        //Notify current player of their game result
        await Clients.Client(playerSession.Id)
            .SendAsync(GameEventNames.GameEnd, playerResult);
        //Compute game result for opposing player
        var opponentResult = _gameService.GetGameResult(
            opponentSession.Play.Value,
            playerSession.Play.Value).ToString();
        //Notify opposing player of their game result
        await Clients.Client(opponentSession.Id)
            .SendAsync(GameEventNames.GameEnd, opponentResult);
    }
    else
    {
        //The opposing player has not made their play yet. We notify the current player to wait for the opposing player to make a choice.
        await Clients.Caller.SendAsync(GameEventNames.WaitingForPlayerToPlay);
    }
}

This method will be called by the player to indicate their choice of Rock, Paper or Scissors. If the opposing player has already made their play, then we compute the game result for each player and notify them with the GameEnd event.

If the opposing player has not yet made their play, we notify the current user to wait for them using the WaitingForPlayerToPlay event.

12. Completing the Game (Client)

Once again, we must modify our client to use the new functionality implemented on the server. We must call the new SelectPlay method on the GameHub and handle the GameEnd and WaitingForPlayerToPlay events.

We will submit the player’s choice when they click the Play button. Since we don’t want the player to change their choice after they click Play, we will have to hide the row which contains the dropdown. In order to display the player’s choice, we will add a new row. Add the following Razor markup before the Scripts section of the Home Index view:

<!--This row will display the choice (rock, paper, scissors) after the player has submitted it-->
<div class="row" style="display:none" id="playChoiceRow">
    <div class="col-3">
        <label>You chose:</label>
    </div>
    <div class="col-3">
        <!--The playChoice span element will container one of the following words based on the players selection: "Rock", "Paper", "Scissors-->
        <span id="playChoice"></span>
    </div>
</div>

Finally, we will add the code to respond to the new events and to call the SelectPlay method on the GameHub when the Play button is clicked. The code should be added at the end of the Scripts section of the Home Index view:

connection.on("@RockPaperScissors.Constants.GameEventNames.WaitingForPlayerToPlay",
    () => { $("#gameStatus").html("Waiting other player to select") });
connection.on("@RockPaperScissors.Constants.GameEventNames.GameEnd",
    (update) => { $("#gameStatus").html(update) });
$("#playButton").click(function () {
    connection.invoke("selectPlay", $("#playSelect").val());
    $("#playChoiceRow").show();
    $("#playChoice").html($("#playSelect").val());
    $("#playRow").hide();
})

13. Test the Game

When we run our app from Visual Studio, we should see the following:

  • When we open a second browser tab and paste the app URL, the status should be In Progress
  • After we select an option in the dropdown and click Play, the status should be Waiting other player to select
  • When we switch to the original browser tab and click Play, the status should change to one of the following: Win, Lose, Draw
  • When we switch back to the second browser tab, the status should also have change to one of the following: Win, Lose, Draw