Building a Game with SignalR and F#

Read Time: 13 minutes

Today’s post is a brief example of how to implement a game using F# and SignalR. Creating a game for bots to play doesn’t have to be overly difficult. Since interesting emergent qualities can arise from simple rules, it makes for a fun way to show off SignalR, beyond the standard chat application. As this post will show, F# and SignalR work well together to create a nice communication framework without requiring a complex setup.

What is the game? It is a bot-played game of multi-player snakes. The rules are simple: eat food to grow, and run into opponents to slice off their tails. To give players a goal, they accrue points based on their length over time. It is a limited enough concept that a game engine and client can be built without overshadowing the SignalR aspects. A picture, or movie, is worth a thousand words. So below is a sample of the game player viewer. What is SignalR? If you’re not familiar, it is a library that provides real-time capabilities to web applications. Think websockets and other related technologies. In this particular case there is a web viewer and a console app leveraging the capability.

GamePlay

With definitions out of the way, time for the technical components. We’ll use .NET Core version 2.2. If you don’t have it installed, head out to the .NET Core Downloads page. Select SDK for your platform. Tangential, but you can also get here by going to dot.net, then navigating to Downloads and .NET Core.

The post will be broken up into 3 primary parts: SignalR server, SignalR client, SignalR webviewer. Discussing the specific game code will be out of scope, since it is the interactions that we really care about.

Server

For the server, Giraffe will be the base. It will host the SignalR services as well as the weS viewer. Creation is similiar to a typical dotnet app, but it’ll use the Giraffe template. If you need the templates you can get them by doing dotnet new -i "giraffe-template::*". The Giraffe template includes a reference to the Microsoft.AspNetCore.App package, which includes SignalR, so no additional packages are necessary.

1
dotnet new giraffe -lang F# -n GameServer -o GameServer 

The Giraffe templates thankfully generate all the necessary boilerplate code for a webapp on top of Kestrel. To simplify, we’ll focus on the components that need to be added to the server code. Add the necessary namespaces, this is not only for SignalR, but to support the background game engine service.

1
2
3
open System.Threading;
open System.Threading.Tasks;
open Microsoft.AspNetCore.SignalR

The SignalR components must be added to the pipeline. This is done in two places. Modify configureApp to include .UseSignalR(...). Modify configureServices to include services.AddSignalR(). In addition, the game runs as a hosted service. To support this, modify configureServices to also includ services.AddHostedService<GameService>().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let configureApp (app : IApplicationBuilder) =
let env = app.ApplicationServices.GetService<IHostingEnvironment>()
(match env.IsDevelopment() with
| true -> app.UseDeveloperExceptionPage()
| false -> app
.UseGiraffeErrorHandler errorHandler)
.UseCors(configureCors)
.UseStaticFiles()
.UseSignalR(fun routes -> routes.MapHub<GameHub>(PathString "/gameHub")) // SignalR
.UseGiraffe(webApp)

let configureServices (services : IServiceCollection) =
services.AddCors() |> ignore
services.AddSignalR() |> ignore // SignalR
services.AddGiraffe() |> ignore
services.AddHostedService<GameService>() |> ignore // GameService

Now that the components have been injected into the pipeline, they need to be created. For this we’ll need to create a SignalR hub as well as a GameService. Starting with the SignalR hub. We can send messages to the SignalR clients by supplying a function name and payload: this.Clients.All.SendAsync("Message", "foo"). But, we can do better by defining the interface and making the calls type-safe, so let’s do that. Below is defined the client api interface. This ensures that calls from server to client match the required types. For simplicity, the server only has 3 messages it can send to clients.

  • LoginResponse Reports success or failure, and their PlayerId if login was successful.

  • Message Sends general notifications to clients.

  • GameState Provides a serialized gamestate that clients act on.

1
2
3
4
type IClientApi = 
abstract member LoginResponse :bool * string -> System.Threading.Tasks.Task
abstract member Message :string -> System.Threading.Tasks.Task
abstract member GameState :string -> System.Threading.Tasks.Task

Now, to define the SignalR hub. This effectively is the listener that all clients connect to. It leverages the IClientApi that was just created. Here we need to write the handlers for messages accepted from clients. Players have four different actions they can signal to the server.

  • Login For brevity, there is no authentication; provide a PlayerName and they get a PlayerId. It also adds a player to the game. The below code demonstrates how the server can send messages to all connected clients or just specific ones.

  • Logout Removes a player from the game.

  • Turn Players have one action they can perform, turn. They move in a specified direction until they turn, then they proceed in that direction.

  • Send Players can blast messages to all clients. Perhaps when the bots become self-aware they can taunt each other.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type GameHub () =
inherit Hub<IClientApi> ()

/// Accept client logins
member this.Login (name :string) =
let connectionId = this.Context.ConnectionId
let success, playerId = addPlayer name
if success then
// Tell client login success and their playerId
this.Clients.Client(connectionId).LoginResponse(true, playerId)
// Tell clients of new player
this.Clients.All.Message(sprintf "New Player: %s (%s)" name playerId)
else
// Tell client login failed
this.Clients.Client(connectionId).LoginResponse(false, "")

/// Handle client logout
member this.Logout (playerId :string) =
removePlayer playerId
// Tell clients of player logout
this.Clients.All.Message(sprintf "Player left: %s" playerId)

/// Handle player changing direction
member this.Turn (playerId :string, direction :string) =
updatePlayerDirection playerId direction

/// Pass along message from one client to all clients
member this.Send (message: string) =
this.Clients.All.Message(message)

Now that the SignalR hub is done, it’s time to make the GameService that performs the server-side game logic as well as sending updated gamestate to players. For this a background service is used. At a set interval it processes current game state updateState and sends it out to all clients. One note here: because I’ve choosen to use a client interface, the hub context is defined as IHubContext<GameHub, IClientApi>). If this wasn’t the case, it would be defined as IHubContext<GameHub> and messages would be sent using this.HubContext.Clients.All.SendAsync("GameState", stateSerialized).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type GameService (hubContext :IHubContext<GameHub, IClientApi>) =
inherit BackgroundService ()

member this.HubContext :IHubContext<GameHub, IClientApi> = hubContext

override this.ExecuteAsync (stoppingToken :CancellationToken) =
let pingTimer = new System.Timers.Timer(TurnFrequency)
pingTimer.Elapsed.Add(fun _ ->
updateState ()
let stateSerialized = serializeGameState gState
this.HubContext.Clients.All.GameState(stateSerialized) |> ignore)

pingTimer.Start()
Task.CompletedTask

Beyond the specific game logic implementation, that’s all there is to the SignalR server. It now will send out gamestate updates as well as handle client messages.

Client

The next step is building the client. To do this, a dotnet console app will be created, and then the SignalR package is added.

1
2
3
dotnet new console -lang f# -n ClientFs
cd ClientFs
dotnet add package Microsoft.AspNetCore.SignalR.Client

Once that is done, it needs the SignalR namespace.

1
open Microsoft.AspNetCore.SignalR.Client

The client needs to make a connection to the SignalR hub. Similar to the server, the client needs some event handlers for server generated messages.

  • LoginResponse A successful login gives the client a playerId.

  • Message - Handle general message notifications.

  • GameState - When the server sends the current gamestate, the client evaluates and then sends an action message back.

  • Closed - When the connection closes, what does the client do? In this case attempts to reconnect.

Once the event handlers are setup, the client connects and performs a login. The handlers take care of the rest. As can be seen below, the client uses InvokeAsync to send messages to the server (as seen in the login).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[<EntryPoint>]
let main argv =
// Create connection to game server
let connection =
(HubConnectionBuilder())
.WithUrl("http://localhost:5000/gameHub")
.Build()

// Event handlers
connection.On<bool, string>("LoginResponse", fun success id -> loginResponseHandler connection success id) |> ignore
connection.On<string>("Message", fun message -> messageHandler message) |> ignore
connection.On<string>("GameState", fun gameState -> gameStateHandler connection gameState) |> ignore
connection.add_Closed(fun error -> reconnect connection error)

// Start connection and login
try
connection.StartAsync().Wait()
connection.InvokeAsync("login", myName).Wait()
with
| ex -> printfn "Connection error %s" (ex.ToString())
Environment.Exit(1)

// Listen for 'q' to quit
getCommand connection

0

The handler logic is uninteresting, but it is useful to see the definitions that match with the handlers. In addition, I’ve included the client’s response back to the server in the gameState handler. Again, it uses InvokeAsync when contacting the server.

1
2
3
4
5
6
7
8
9
10
11
12
let loginResponseHandler (connection :HubConnection) (success :bool) (id :string) =
...

let messageHandler (message :string) =
...

let gameStateHandler (connection :HubConnection) (gameState :string) =
...
connection.InvokeAsync("Turn", playerId, move.ToString()) |> ignore

let rec reconnect (connection :HubConnection) (error :'a) =
...

Game Viewer

The final piece to address is the game viewer. This comes in two parts: the layout and the code. For the layout, we leverage Giraffe’s view engine. It’s a simple view that contains an html canvas map, player list, messages display, and a state print (for debugging purposes). This is also where supporting js libraries: signalr, jquery, as well as the viewer game-server.js are included. For this project, the files reside in the WebRoot directory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
module Views =
open GiraffeViewEngine

let layout (content: XmlNode list) =
html [] [
head [] [
title [] [ encodedText "SnakeWorld" ]
link [ _rel "stylesheet"
_type "text/css"
_href "/main.css" ]
]
body [] content
]

let index (model : Message) =
[
div [ _class "container"] [
div [ _class "row" ] [
div [ _id "mapWrapper"; _class "col-6" ] [
canvas [ _id "worldMap"; _class "world-map"; _width "200"; _height "200" ] [];
div [ _id "playerList"; _class "player-list" ] []
]
]
div [ _class "row" ] [
div [ _id "message"; _class "col-6" ] []
]
div [ _class "row" ] [
div [ _id "currentState"; _class "col-6" ] []
]
]
script [ _src "signalr.js" ] []
script [ _src "jquery-3.3.1.min.js" ] []
script [ _src "game-viewer.js" ] []
] |> layout

This may bring up a question, where did signalr.js come from? Well, there is one more thing we need to add to the project. In a real project I’d package this differently, but a quick and dirty way will do for now.

1
2
npm install @aspnet/signalr
cp ./node_modules/@aspnet/signalr/dist/browser/signalr.js ./WebRoot

The code part of the game viewer is in javascript. A similar process is required as was performed with the F# client. A connection is created to the SignalR hub. Then event handlers are wired up. The viewer is read-only, to show messages and draw the map and player score list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// SignalR connection
const connection = new signalR.HubConnectionBuilder().withUrl("/gameHub").build();

/// Handle Connection start
connection.start().catch(function (err) {
return console.error(err.toString());
});

/// Handle incoming message
connection.on("Message", function (message) {
$("#message").text(message);
});

/// Handle game state updates (draw map, update player list)
connection.on("GameState", function (gameState) {
handleGameState(JSON.parse(gameState))
});

At this point, we have all the necessary parts to support a SignalR F# server, F# client, and javascript client. That closes the loop on the communication framework. From here the game logic can be added to the server and client, and drawing can be added to the viewer. Those components are outside of the scope for this post. I hope you’ve found this to be a useful guide to leveraging a SignalR implementation with F#. Until next time…