My challenge

The game is looking good and gets us playing - as long as everyone behaves. By behaving I mean staying at the game web page and never allowing their device to lock the screen - otherwise the game will enter a random state and start sending people inconsistent instructions.
My friends soon proved that my expectation on my users are way too high. They have to Google for the word from time to time, and they have to put their phone on their belly more often :D
So it is up to me to make the game survive these.

The reason for the random behaviour

Currently, the game is driven by the updates of player states. Here’s a code snippet:

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
35
36
37
// game.go

func (room *Room) runTalkRound() bool {
room.setAllAlivePlayersToState(PlayerListeningState)
alivePlayers := room.getAlivePlayerPointersInRoom()
if len(alivePlayers) < 1 {
room.setAllPlayersToState(PlayerIdleState)
room.broadcastPlayersState("No alive players in room", "", AlertTypeWarning)
return false
}

rand.Seed(time.Now().UnixNano())
startFrom := rand.Intn(len(alivePlayers))
i := startFrom
for {
alivePlayers[i].State = PlayerTalkingState
room.broadcastPlayersState("", fmt.Sprintf("%s's turn to talk", alivePlayers[i].Nickname), "")
waitForState(func() bool { return alivePlayers[i].State == PlayerTalkFinishedState })
alivePlayers[i].State = PlayerListeningState
i++
if i == len(alivePlayers) {
i = 0
}
if i == startFrom {
return true
}
}
}

func waitForState(check func() bool) {
for {
time.Sleep(time.Second)
if check() {
return
}
}
}

If a player leaves the room (i.e. the player’s pointer gets removed from the room.players map) in the middle of the talking round, the alivePlayers slice still has the reference to this player, but the game might be stuck waiting for this player to change state.

Available tools

To solve this problem, I need to know what relevant tools are available.

Reconnecting WebSocket

One problem for now is that when a player loses their connection, they lose it for good. The solution would be to add retries upon onclose. Luckily there’s already wrapper packages that provide this functionality on top of WebSocket. reconnecting-websocket looks like what I need.

go context package

Another challenge I face is the management of ongoing games when an unexpected event happens - for example, when a player quits unexpectedly. This means I need something like CancellationToken in C# - luckily, there is context package in go that takes care of cancellation signals.

This is a thorough walkthrough of what it is like to use go context.

Actions taken

Step 1: Reconnecting WebSocket

At this moment, there’s no attempt to reconnect the player if the websocket connection is lost. The first step towards a more robust game experience would be an automatic connection recovery.

Step 2: Manage offline players’s state

All buggy behaviours start from me removing the player pointer whenever their connection is lost. I should allow the player to be in an “offline/appears away” state, which can:

  • interact with other actively connected players, as dictated by the server
  • later be recovered to an active state, if a request to re-connect is received.
  • used to calculate the game result, if for example, the spy is offline and there’s no point keep talking.

A key to implementing this update is to dig deeper into player.readPump and player.disconnect:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// readPump reads new messages from a player's connection
func (player *Player) readPump() {
defer func() {
player.disconnect()
}()

player.conn.SetReadLimit(maxMessageSize)
player.conn.SetReadDeadline(time.Now().Add(pongWait))
player.conn.SetPongHandler(func(string) error { player.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })

// Start endless read loop, waiting for messages from player
for {
_, jsonMessage, err := player.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("unexpected close error: %v", err)
}
break
}
player.handleNewMessage(jsonMessage)
}
}
1
2
3
4
5
func (player *Player) disconnect() {
player.room.unregister <- player
close(player.send)
player.conn.Close()
}

Before making the change, whenever the player is unregistered from the room, the player is removed from the room’s players map; now they are not, so it is my responsibility to ensure that the server will now try to send message to the player’s websocket connection - otherwise the server will crash.

This is implemented in this commit.

Step 3: Handle the game state change if a player appears away/ comes back

The gaming experience would have been damaged when a player leaves unexpectedly when:

  • no one knows if the player that left is a spy
  • when the player joins the room again, they will be recognized as a new user. They will re-trigger a game state refresh, causing everyone to receive a new word, and the current game is lost.

Therefore, the server should take over the responsibility to:

  • do a check to see if the game can still go on without the player that already left
  • keep track of the player that left, and allow them to come back in a non-destructive way
  • stop new players from joining a room that has a game running.

Step 3.1 Make state check functions more expressive

To make ground for the change to handle player states, I made the state check functions more expressive with variadic parameter. Now I can easily check for multiple player states at the same time, or wait for one of multiple conditions to turn true before the game can continue - instead of being stuck with a single check.

Step 3.1 Find out a way to update game state when a player appears away/ comes back

Option 1

The current implementation has a goroutine to run the game. Each “blocking” stage (e.g. waiting for all players to vote) waits for the players’ state to update, and now it is important that the offline state gets checked too.

Let me start from the waitForState function and allow it to handle special cases.

1
2
3
4
5
6
7
8
9
10
11
12
13
func waitForState(check func() bool) {
for {
time.Sleep(time.Second)

// TODO Add special case and handler function

// TODO check if someone has been away

if check() {
return
}
}
}
Option 2

Create a separate goroutine to monitor player that appears away - long running while game status is active, and communicates with other goroutine via game status.

Implementation

I decided on option 2 - a long running health check routine, as this is more generic and more resilient to different timing of player leaving the room. See this commit that adds healthcheck and this commit that tidies up at end of game.