Building a 1v1 Game Matchmaking Service in Go – Part 2

This article is part 2 of a series.

Part 1 – Domain Modeling

The associated code repo is here.

Turning the Domain Model into Code

In Part 1, we modeled our business process, and now we need to turn that into code so we can operationalize it. We’re going to build our system using the Ports & Adapters architecture (aka Hexagonal architecture). Ports & Adapters is a domain driven architecture that gives us a framework for organizing our code in a domain-centric way. In other words, the business rules comes first, and the code is just there to support operationalizing our business process.

Our project layout will look like this:

matchmaker-go/
  |-adapters/
  |-domain/
    |-models/
    |-ports/
    |-services/
  |-go.mod

We start with the application core. We will call this the Service. This is the piece that is responsible for executing the domain operations. Recall from article 1 we have this domain model:

# Input (User Actions)
- Find Match
  + Player Id
- Leave Matchmaking (Cancel Finding Match)
  + Player Id

# Output (Feedback)
- Match Made
  + Player 1 Id
  + Player 2 Id
- Finding Match (Searching for Opponent)
- Left Matchmaking (Cancelled Searching For Opponent)

User Actions will translate directly into function calls on the MatchmakingService. Additional data related to the actions become function parameters. Feedback will translate directly into function return values on the service. If you are a careful observer, you may have noticed that we renamed User Actions and Feedback to Input and Output , respectively. Why rename those? We are shifting from modeling to building the system. As we build the system, it is helpful to name things according to the structure of the system to make it clear what lives where and how the pieces fit together. We also renamed some of the individual inputs and outputs to align the language around match.

Some Input can result in multiple different kinds of Output depending on the outcome of the operations, so some Output types will need to be part of the same type hierarchy. The Output types will go in our models folder. Let’s create our MatchmakingService

// services/matchmaking_service.go
package services

import "github.com/shadowCow/matchmaker-go/domain/models"

type MatchmakingService struct {}

func (m *MatchmakingService) FindMatch(playerId string) (models.FindMatchOutput, error) {
    panic("not implemented")
}

func (m *MatchmakingService) LeaveMatchmaking(playerId string) (models.LeaveMatchmakingOutput, error) {
    panic("not implemented")
}

…and the models for our Outputs.

// models/output.go
package models

type FindingMatch struct {
    PlayerId string
}

type MatchMade struct {
    Player1Id string
    Player2Id string
}

type LeftMatchmaking struct {
    PlayerId string
}

type FindMatchOutput interface {
    findMatchOutput()
}
func (f FindingMatch) findMatchOutput() {}
func (m MatchMade) findMatchOutput() {}

type LeaveMatchmakingOutput interface {
    leaveMatchmakingOutput()
}
func (l LeftMatchmaking) leaveMatchmakingOutput() {}

A few details here warrant some brief discussion. Let’s start with the return type for FindMatch

(models.FindMatchOutput, error)

Any business action can fail, for all kinds of reasons. We are modeling the FindMatch action as an operation which can either succeed or fail. There may be different errors that can occur, just as there may be different successes which can occur. In this case, the successful outcomes are either (A) – you successfully join matchmaking, and you’re waiting for a match to be made, or (B) – you are immediately matched up with someone who has been waiting for a match. We model that success as a single type with multiple variants. In Go, this means defining an interface and implementing it on each struct which is a variant of that type. Why not just define an Output interface and make all Outputs variants of it? We want our model to be precise and reflect our business rules as faithfully as possible so the compiler can help find mistakes, and so that our code communicates the business rules. The business action FindMatch cannot result in LeftMatchmaking as an output, it can only result in FindingMatch, MatchMade, or an unexpected error. What about the following return signature?

(*models.FindingMatch, *models.MatchMade, error)

This also communicates the function returns either FindingMatch, MatchMade, or an error. But it doesn’t clearly communicate that it returns exactly one of those.

Ok, we’ve got the basic structure in place. Now we need to encode the behaviors of the system. Recall from Part 1 we have the following 2 scenarios (with the language updated to align with our other language changes).

# Scenario 1
- Player 1 sends FindMatch message to MatchmakingService
- MatchmakingService sends FindingMatch message to Player 1
- Player 2 sends FindMatch message to MatchmakingService
- MatchmakingService sends MatchMade message to Player 2
- MatchmakingService sends MatchMade message to Player 1

# Scenario 2
- Player 1 sends FindMatch message to MatchmakingService
- MatchmakingService sends FindingMatch message to Player 1
- Player 1 sends LeaveMatchmaking message to MatchmakingService
- MatchmakingService sends LeftMatchmaking message to    
Player 1
- Player 2 sends FindMatch message to MatchmakingService
- MatchmakingService sends FindingMatch message to Player 2

These behaviors will be encoded as Tests on our MatchmakingService. In Ports & Adapters terms, these are the Use Cases. Before we write those tests we need to translate this language of sends X message to Y to something that aligns better with our code. The phrasing sends message could mean many different things in terms of how we build the system. Does that translate into a purely asynchronous messaging system? Does that translate into client/server request/response calls? We’re building this with Go – does that translate into channels? Again, we’re going to defer answering those questions. In Ports & Adapters, those are infrastructure concerns that come later. For the moment, we are building the domain logic. A single action is taken, and that action has an outcome. Later, we will put adapters in place to sit between the clients that call the service and the domain logic inside the service. Those adapters will handle the messaging details.

Here are those scenarios, translated to map closely to our MatchmakingService. We also introduce the Arrange-Act-Assert pattern here, using the Given-When-Then syntax.

# Scenario 1
# Make a match
Given
- Player 1 Id is "p1"
- Player 2 Id is "p2"
When
- MatchmakingService.FindMatch is called with Player 1 Id
Then
- FindingMatch is returned
When
- MatchmakingService.FindMatch is called with Player 2 Id
Then
- MatchMade is returned with Player 1 Id and Player 2 Id

# Scenario 2
# Leave matchmaking
Given
- Player 1 Id is "p1"
- Player 2 Id is "p2"
When
- MatchmakingService.FindMatch is called with Player 1 Id
Then
- FindingMatch is returned
When
- MatchmakingService.LeaveMatchmaking is called with Player 1 Id
Then
- LeftMatchmaking is returned
When
- MatchmakingService.FindMatch is called with Player 2 Id
Then
- FindingMatch is returned

Now we’ll turn those use cases into tests on the MatchmakingService.

package services_test

import (
    "testing"

    "github.com/matryer/is"
    "github.com/shadowCow/matchmaker-go/domain/models"
    "github.com/shadowCow/matchmaker-go/domain/services"
)

func TestMakeMatch(t *testing.T) {
    is := is.New(t)

    // Given
    player1Id := "p1"
    player2Id := "p2"
    service := services.NewMatchmakingService()

    // When
    gotP1, err := service.FindMatch(player1Id)

    // Then
    wantP1 := models.FindingMatch{}
    is.NoErr(err)
    is.Equal(gotP1, wantP1)

    // When
    gotP2, err := service.FindMatch(player2Id)

    // Then
    wantP2 := models.MatchMade{
        Player1Id: player1Id,
        Player2Id: player2Id,
    }
    is.NoErr(err)
    is.Equal(gotP2, wantP2)
}

func TestLeaveMatchmaking(t *testing.T) {
    is := is.New(t)

    // Given
    player1Id := "p1"
    player2Id := "p2"
    service := services.NewMatchmakingService()

    // When
    gotFindMatchP1, err := service.FindMatch(player1Id)

    // Then
    wantFindMatchP1 := models.FindingMatch{}
    is.NoErr(err)
    is.Equal(gotFindMatchP1, wantFindMatchP1)

    // When
    gotLeaveMatchmakingP1, err := service.LeaveMatchmaking(player1Id)

    // Then
    wantLeaveMatchmakingP1 := models.LeftMatchmaking{}
    is.NoErr(err)
    is.Equal(gotLeaveMatchmakingP1, wantLeaveMatchmakingP1)

    // When
    gotFindMatchP2, err := service.FindMatch(player2Id)

    // Then
    wantFindMatchP2 := models.FindingMatch{}
    is.NoErr(err)
    is.Equal(gotFindMatchP2, wantFindMatchP2)
}

When we run these tests, they fail, because we haven’t implemented the business logic for FindMatch or LeaveMatchmaking yet. Let’s do that now.

Recall from Part 1 we decided to start with a first come, first served matchmaking policy. At this point we need to start thinking about the possible states our system can be in, and how those states affect how we process inputs and turn them into outputs. A computer program is a state machine. Whenever we are building a program, we are building a state machine, which consists of State, Input, and Output. Thinking of the system in those terms helps us keep track of all the possible system behaviors and edge cases, which helps us ensure correctness and avoid crashes. The minimalist way to implement a first come, first served matchmaking policy is to let our system have two states:

- Empty (No players in matchmaking)
- Full (One player in matchmaking)

We only need to store one player on our state, because any time a 2nd player comes in, they are immediately matched up with the most recent player. Now we can write out the possible state transitions using the following syntax:

(State, Input) -> (State, Output)
# or, in shorthand
(S, I) -> (S, O)

# Which means...
# When we have this pair:
# (State, Input)
# Then the system should transition
# ->
# to the next State
# And produce this Output
# (State, Output)

And here are the state transitions for our system:

Empty, FindMatch(id) -> Full(id), FindingMatch(id)
Empty, LeaveMatchmaking(id) -> Empty, nil
Full(id1), FindMatch(id2) -> Empty, MatchMade(id1, id2)
Full(id), FindMatch(id) -> Full(id), FindingMatch(id)
Full(id), LeaveMatchmaking(id) -> Empty, LeftMatchmaking(id)
Full(id1), LeaveMatchmaking(id2) -> Full(id1), LeftMatchmaking(id2)

Now we can implement this on our MatchmakingService.

type MatchmakingService struct {
    queuedPlayerId string
}
func NewMatchmakingService() *MatchmakingService {
    return &MatchmakingService{}
}

func (m *MatchmakingService) FindMatch(playerId string) (models.FindMatchOutput, error) {
    if m.queuedPlayerId == "" {
        m.queuedPlayerId = playerId

        return models.FindingMatch{ PlayerId: playerId }, nil
    } else if m.queuedPlayerId == playerId {
        return models.FindingMatch{ PlayerId: playerId }, nil
    } else {
        output := models.MatchMade{
            Player1Id: m.queuedPlayerId,
            Player2Id: playerId,
        }
		
        m.queuedPlayerId = ""

        return output, nil
    }
}

func (m *MatchmakingService) LeaveMatchmaking(playerId string) (models.LeaveMatchmakingOutput, error) {
    if m.queuedPlayerId == playerId {
        m.queuedPlayerId = ""
    }

    return models.LeftMatchmaking{ PlayerId: playerId }, nil
}

Now we run our tests and they pass. We’ve done it! We’ve built a first iteration of the domain logic for our MatchmakingService. Let’s discuss some of the implementation decisions.

We decided to use a single string field queuedPlayerId to represent our two states, Empty and Full. When queuedPlayerId is the empty string, that is the Empty state. When it is set to a player id, that is the Full state. We could have made that more explicit with a State interface and Empty and Full struct variants. That could be a better path to take when we have more states or our states are more complex.

We also decided that ‘no-op’ calls, such as a call LeaveMatchmaking when you aren’t already in matchmaking, will simply return the success variant of the outcome. In other words, when the caller calls LeaveMatchmaking, their intent is that at the end of that operation, they are not in matchmaking. Returning LeftMatchmaking conveys that. We could add a new output variant like NoChange to represent situations like that to more precisely reflect the idea that the action had no effect on the system.

Before we wrap up Part 2, we’re going to add a few more tests to make sure all of our possible state transitions are covered.

func TestFindMatchForSamePlayer(t *testing.T) {
    is := is.New(t)

    // Given
    player1Id := "p1"
    service := services.NewMatchmakingService()

    // When
    got1, err := service.FindMatch(player1Id)

    // Then
    want := models.FindingMatch{ PlayerId: player1Id }
    is.NoErr(err)
    is.Equal(got1, want)

    // When
    got2, err := service.FindMatch(player1Id)

    // Then
    is.NoErr(err)
    is.Equal(got2, want)
}

func TestLeaveMatchmakingForDifferentPlayer(t *testing.T) {
    is := is.New(t)

    // Given
    player1Id := "p1"
    player2Id := "p2"
    service := services.NewMatchmakingService()

    // When
    gotFindMatchP1, err := service.FindMatch(player1Id)

    // Then
    wantFindMatchP1 := models.FindingMatch{ PlayerId: player1Id }
    is.NoErr(err)
    is.Equal(gotFindMatchP1, wantFindMatchP1)

    // When
    gotLeaveMatchmakingP2, err := service.LeaveMatchmaking(player2Id)

    // Then
    wantLeaveMatchmakingP2 := models.LeftMatchmaking{ PlayerId: player2Id }
    is.NoErr(err)
    is.Equal(gotLeaveMatchmakingP2, wantLeaveMatchmakingP2)

    // When
    gotFindMatchP2, err := service.FindMatch(player2Id)

    // Then
    wantFindMatchP2 := models.MatchMade{
        Player1Id: player1Id,
        Player2Id: player2Id,
    }
    is.NoErr(err)
    is.Equal(gotFindMatchP2, wantFindMatchP2)
}

Now when we run our tests with coverage…

go test -cover ./...

…we’ll see that we have 100% coverage on our services package. We want to maintain that level of coverage for our domain logic indefinitely.

Take note of the style of validations used in these tests. We only call the exported functions on the MatchmakingService and make validations on the returned values. We do not inspect any of the internal state of MatchmakingService. This is important for easily evolving our system. Desired system behaviors are more stable than internal implementation details. These tests describe the system behaviors. We can change the internal details of how MatchmakingService implements those behaviors without needing to change the tests, and we can run the tests at any time to tell us if MatchmakingService is still behaving correctly. This also means we need to chain Act-Assert or When-Then pairs together, because we need to perform a sequence of actions against our service to validate its behavior.

Next

Now that we have a working iteration of our business logic, we can start thinking about how to deploy this service and how to modify our business logic to support more interesting behaviors. We’ll do that in Part 3.

You can view all the code changes we made in Part 2 here.