Skip to main content

Director

Open Match Director Edgegap's tutorial (basic)#

The Director component of Open Match is the orchestrator of everything. This component requests game proposals and assigns game server to them.

Prerequisites#

Create a new Go project#

Every Open Match service should be in its own project. Create a new folder for the OpenMatch director component.

In this folder, run the following command.

go mod init director

And create a file named main.go where the core logic of the component will be.

Create the main loop#

The first thing that we need for our Director is a loop that will match players every 5 seconds.

Now, in your main.go file, you can add the following code. This task will create the main loop.

package main
import (
"fmt"
"time"
)
const (
openMatchMatchFunctionHost = "basics-match-function"
openMatchMatchFunctionPort = 50502
openMatchBackendService = "open-match-backend:50505"
// Game server data
gameServerPort = "<APP_PORT>" // String | E.G. 25565
appName = "<APP_NAME>" // E.G. MySuperGame
appVersion = "<APP_VERSION>" // E.G. V1
arbitriumAPI = "<ARBITRIUM_API>" // E.G. https://staging-api.edgegap.com/
apiToken = "<API_TOKEN>" // E.G. 1111aa11aa11111a1aa11111d111a111111111a1
)
func main() {
fmt.Println("Stating Edgegap tutorial's Director!")
for range time.Tick(time.Second * 5) {
fmt.Println("Creating matches...")
}
}

You can try it by starting your app with go run ./main.go. Every 5 seconds, you should see

Creating matches...

Notes#

Don't forget to change the variables gameServerPort, appName, appVersion, arbitriumAPI, apiToken with valid data. The values should come from Arbitrium

Get Profiles#

In order to obtain match proposals, we will need profiles. We will create a method that returns a list of profiles. We will need Open Match dependency for that.

go get open-match.dev/open-match/pkg/pb

Here is the code for profiles generation

// Get all the possible profiles
func getProfiles() []*pb.MatchProfile {
var profiles []*pb.MatchProfile
modes := []string{"mode.casual", "mode.ranked", "mode.private"}
for _, mode := range modes {
profiles = append(profiles, &pb.MatchProfile{
Name: fmt.Sprintf("%s_profile", mode),
Pools: []*pb.Pool{
{
Name: "pool_" + mode,
TagPresentFilters: []*pb.TagPresentFilter{
{
Tag: mode,
},
},
},
},
},
)
}
return profiles
}

You will have to change your dependencies. They should look like this

import (
"fmt"
"time"
"open-match.dev/open-match/pkg/pb"
)

Create a connection to Open Match Backend service#

We will retrieve the match proposals by communicating to Open Match Backend service via gRPC. We need to create the connection.

For that, we need gRPC dependency

go get google.golang.org/grpc

Here is the code that creates the connection.

// Create a connection to Open Match Backend service
func newBackendConnection() pb.BackendServiceClient {
conn, err := grpc.Dial(
openMatchBackendService, grpc.WithInsecure(),
)
if err != nil {
log.Printf("Error while communicating with Open Match Backend, err: %v", err.Error())
}
return pb.NewBackendServiceClient(conn)
}

You will have to change your dependencies. They should look like this

import (
"fmt"
"time"
"log"
"google.golang.org/grpc"
"open-match.dev/open-match/pkg/pb"
)

Fetching match proposals#

The next step would be to create a function that will fetch the match proposals for a specific profile. We will make a call to Open Match Back End service.

Here is the piece of code for that.

// fetch Fetch profile's matches
func fetch(p *pb.MatchProfile, backendService pb.BackendServiceClient) ([]*pb.Match, error) {
// Making request object
req := &pb.FetchMatchesRequest{
Config: &pb.FunctionConfig{
Host: openMatchMatchFunctionHost,
Port: openMatchMatchFunctionPort,
Type: pb.FunctionConfig_GRPC,
},
Profile: p,
}
// Getting match proposals
stream, err := backendService.FetchMatches(context.Background(), req)
if err != nil {
return nil, err
}
var result []*pb.Match
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
result = append(result, resp.GetMatch())
}
return result, nil
}

You will have to change your dependencies. They should look like this

import (
"fmt"
"time"
"log"
"io"
"context"
"google.golang.org/grpc"
"open-match.dev/open-match/pkg/pb"
)

Assign game server to match proposals#

We need a function that will assign a game server to our match proposals. It is here that we will call Arbitrium API (CoDeMa). You could make an HTTP request by following our API call definitions; however, we will use an auto-generated Go SDK. You can learn how to generate an SDK here. In your director project, you will have to create a swagger folder and put the generated SDK inside.

Using Arbitrium deployment#

This section shows you how to integrate Arbitrium's deployment into your Director. If you use Arbitrium's session, you should go to the next section.

Info : 

We assume that you already experimented with Arbitrium's deployment and know how they work. If you don't, we recommend you to see this section

// assign Deploy a game server with CoDeMa API and assign its IP to match's tickets
func assign(matches []*pb.Match, backendService pb.BackendServiceClient) error {
for _, match := range matches {
// Getting Tickets ID and players IP
ticketIDs := []string{}
IPS := []string{}
for _, t := range match.GetTickets() {
ticketIDs = append(ticketIDs, t.Id)
IPS = append(IPS, string(t.Extensions["playerIp"].Value))
}
// Game server port
gamePort := gameServerPort
// Deploying game server and getting ip
conn, err := getServerIP(IPS, gamePort)
if err != nil {
return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err)
}
// Assign game server to tickets
req := &pb.AssignTicketsRequest{
Assignments: []*pb.AssignmentGroup{
{
TicketIds: ticketIDs,
Assignment: &pb.Assignment{
Connection: conn,
},
},
},
}
if _, err := backendService.AssignTickets(context.Background(), req); err != nil {
return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err)
}
log.Printf("Assigned server %v to match %v", conn, match.GetMatchId())
}
return nil
}
// getServerIP Deploy the Game server and fetch its IP when the server is ready
func getServerIP(ips []string, gamePort string) (string, error) {
// Creating Arbitrium deploy request
payload := swagger.DeployModel{
AppName: appName,
VersionName: appVersion,
IpList: ips,
}
// Creating API Client to communicate with arbitrium
configuration := swagger.NewConfiguration()
configuration.BasePath = arbitriumAPI
client := swagger.NewAPIClient(configuration)
auth := context.WithValue(context.Background(), swagger.ContextAPIKey, swagger.APIKey{
Key: apiToken,
Prefix: "token",
})
// Deploying
request, _, err := client.DeploymentsApi.Deploy(auth, payload)
if err != nil {
log.Printf("Could not deploy game server, err: %v", err.Error())
return "", err
}
timeout := 30.0
start := time.Now()
status := ""
var response swagger.Status
// Waiting for the server to be ready
for status != "Status.READY" && time.Since(start).Seconds() <= timeout {
response, _, err = client.DeploymentsApi.DeploymentStatusGet(auth, request.RequestId)
if err != nil {
log.Printf("Error while fetching status, err: %v", err.Error())
}
status = response.CurrentStatus
time.Sleep(1 * time.Second) // let's wait a bit
}
if time.Since(start).Seconds() > timeout {
return "", errors.New("Timeout while waiting for deployment")
}
return fmt.Sprintf("%s:%d", response.PublicIp, response.Ports[gamePort].External), nil
}

You will have to change your dependencies. They should look like this

import (
"fmt"
"time"
"log"
"io"
"context"
"errors"
"director/swagger"
"google.golang.org/grpc"
"open-match.dev/open-match/pkg/pb"
)

Using Arbitrium session#

This section shows you how to integrate Arbitrium's session into your Director. If you use Arbitrium's deployment, you should go to the previous section.

Info : 

We assume that you already experimented with Arbitrium's deployment and know how they work. If you don't, we recommend you to see this section

Warning : 

If you App Version (session kind) IS NOT in auto deploy, you will need an instance running to link your session.

If you App Version (session kind) IS in auto deploy, It will deploy server automatically. It could end up in multiple instances running.

// assign Deploy a game server with CoDeMa API and assign its IP to match's tickets
func assign(matches []*pb.Match, backendService pb.BackendServiceClient) error {
for _, match := range matches {
// Getting Tickets ID and players IP
ticketIDs := []string{}
IPS := []string{}
for _, t := range match.GetTickets() {
ticketIDs = append(ticketIDs, t.Id)
IPS = append(IPS, string(t.Extensions["playerIp"].Value))
}
// Game server port
gamePort := gameServerPort
// Deploying game server and getting ip
conn, err := getServerIP(IPS, gamePort)
if err != nil {
return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err)
}
// Assign game server to tickets
req := &pb.AssignTicketsRequest{
Assignments: []*pb.AssignmentGroup{
{
TicketIds: ticketIDs,
Assignment: &pb.Assignment{
Connection: conn,
},
},
},
}
if _, err := backendService.AssignTickets(context.Background(), req); err != nil {
return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err)
}
log.Printf("Assigned server %v to match %v", conn, match.GetMatchId())
}
return nil
}
// getServerIP Deploy the Game server and fetch its IP when the server is ready
func getServerIP(ips []string, gamePort string) (string, error) {
// Creating Arbitrium deploy request
payload := swagger.SessionModel{
AppName: appName,
VersionName: appVersion,
IpList: ips,
}
// Creating API Client to communicate with arbitrium
configuration := swagger.NewConfiguration()
configuration.BasePath = arbitriumAPI
client := swagger.NewAPIClient(configuration)
auth := context.WithValue(context.Background(), swagger.ContextAPIKey, swagger.APIKey{
Key: apiToken,
Prefix: "token",
})
// Deploying
request, _, err := client.SessionsApi.SessionPost(auth, payload)
if err != nil {
log.Printf("Could not deploy game server, err: %v", err.Error())
return "", err
}
timeout := 30.0
start := time.Now()
ready := false
var response swagger.SessionGet
// Waiting for the server to be ready
for !ready && time.Since(start).Seconds() <= timeout {
response, _, err = client.SessionsApi.GetSession(auth, request.SessionId)
if err != nil {
log.Printf("Error while fetching status, err: %v", err.Error())
}
ready = response.Ready
time.Sleep(1 * time.Second) // let's wait a bit
}
if time.Since(start).Seconds() > timeout {
return "", errors.New("Timeout while waiting for deployment")
}
return fmt.Sprintf("%s:%d", response.Deployment.PublicIp, response.Deployment.Ports[gamePort].External), nil
}

You will have to change your dependencies. They should look like this

import (
"fmt"
"time"
"log"
"io"
"context"
"errors"
"director/swagger"
"google.golang.org/grpc"
"open-match.dev/open-match/pkg/pb"
)

Putting everything together#

Now it's time to put all the pieces together. Our main function should look like this.

func main() {
fmt.Println("Stating Edgegap tutorial's Director!")
backendService := newBackendConnection()
for range time.Tick(time.Second * 5) {
fmt.Println("Creating matches...")
var wg sync.WaitGroup
for _, p := range getProfiles() {
wg.Add(1)
go func(wg *sync.WaitGroup, profile *pb.MatchProfile) {
defer wg.Done()
matches, err := fetch(profile, backendService)
if err != nil {
log.Printf("Failed to fetch matches for profile %v, got %s", profile.GetName(), err.Error())
return
}
log.Printf("Generated %v matches for profile %v", len(matches), profile.GetName())
if err := assign(matches, backendService); err != nil {
log.Printf("Failed to assign servers to matches, got %s", err.Error())
return
}
}(&wg, p)
}
wg.Wait()
}
}

You will have to change your dependencies. They should look like this

import (
"fmt"
"time"
"log"
"io"
"context"
"errors"
"sync"
"director/swagger"
"google.golang.org/grpc"
"open-match.dev/open-match/pkg/pb"
)

It will be hard to test this service in a standalone context. We will see it in action when all the services (Front end, Director, and Match Function) will be ready.

Containerize your service#

Containerization is powerful. You can learn more about it on Edgegap's documentation or Docker. Our container, we will use golang:1.15 for its base image.

Create a file named Dockerfile and write this inside.

FROM golang:1.15
COPY . /go/src/app
WORKDIR /go/src/app
RUN go get -d -v /go/src/app
RUN go install -v /go/src/app
WORKDIR /go/bin
CMD ["director"]

On your local repository#

You can now build your image using.

docker build -t edgegap-om-tuto-director:tuto .

You can run it using this command, but without all the other services, it will not work.

docker run -it --name om-tuto-director edgegap-om-tuto-director:tuto

On an external repository#

You can now build your image using.
(If you use Edgegap's harbor, <REPOSITORY_MAME> is harbor.edgegap.net/<YOUR_PROJECT>)

docker build -t <REPOSITORY_MAME>/edgegap-om-tuto-director:tuto .

You can run it using this command, but without all the other services, it will not work.

docker run -it --name om-tuto-director <REPOSITORY_MAME>/edgegap-om-tuto-director:tuto

Now push your image on your Docker repository. (You may have to use Docker login)

docker push <REPOSITORY_MAME>/edgegap-om-tuto-director:tuto
Warning : If you push your image on a public repository, anybody could pull your image and access the token in the apiToken variable. If you push on a public repository, you should delete your token after this tutorial.

Conclusion#

You now have a basic Open Match Director service. This is not production-ready. However, you will learn the key function of Open Match. You also created a Docker image and pushed it to your repository.

Warning : The code in this tutorial is only an introduction. Unfortunately, there’s no mechanism to clean your deployments. If they do not deploy correctly, the tickets won’t be assigned. In the next loop, the director will try to deploy a new server.