Build Your Own Matchmaking Service with AccelByte Cloud's Golang SDK in AWS Lambda
Overview
In this documentation, you will learn how to build and customize your own Matchmaking service using AccelByte Cloud's Golang SDK. This guide will show you how to create a Matchmaking service where two players can play together. The implementation covered in this guide will flow as follows:
Prerequisites
Ensure that you have followed the prerequisites when creating the AWS .yaml script
Your .yaml script is configured
You have a clone of the latest AccelByte Cloud Golang SDK version
IMPORTANT
We recommend using the specific latest version of AccelByte Cloud's Golang SDK when starting a new project instead of the command latest to avoid issues when the SDK version is updated.
Inside the repository, you will work with the sample folders:
Folder | Usage |
---|---|
title-matchmaking | Sample code to work with the aws-lambda and client folders for matchmaking testing |
cli | CLI to test the matchmaking |
Configure the repository by following AccelByte Cloud's Golang SDK Getting Started Guide.
Files Setup
After you've cloned the Golang SDK, check the following places in your repository:
Open samples/title-matchmaking/aws-lambda to start working with the sample code for the Matchmaking Server-side SDK. We will be using the logic of AWS Lambda to call the main function directly, and the main function will then call the handler. The handler will manage the requests, logic, and responses.
REFERENCES
See the AWS documentation to learn how to work with the AWS Lambda function handler in Go or our reference in the repository.
Ensure you have the following handler code included in the main.go file. This code will directly call the handler that contains the logic required to start the Matchmaking service in AWS Lambda.
lambda.Start(titleMMService.StartMatchmaking)
Authorization
Authorize access to AccelByte Cloud's API using following steps: a. Ensure that you have created a User and a Game Client in the Admin Portal.
b. Open the IAM Swagger page from the OAuth2 authorize API: /iam/v3/oauth/authorize. A Request ID will be generated.
c. Log in using the user_name and password from the Authentication API: /iam/v3/authenticate. Input the Request ID generated in the previous step. If the request succeeds, you will receive an authorization code which will be used in the next step.
d. Generate the user token using the authorization code from the OAuth2 Access Token Generation: /iam/v3/oauth/token. If the request succeeds, you will receive a user token which will be used in Step 2.
e. Generate an OAuth Client token using client_credentials from the OAuth2 Access Token Generation: /iam/v3/oauth/token. If the request succeeds, you will receive an OAuth Client token which will be used when implementing functions related to the DSMC.
Parse the user token into the object's model with the OauthmodelTokenResposeV3 field below:
type OauthmodelTokenResponseV3 struct {
// access token
// Required: true
AccessToken *string `json:"access_token"`
// bans
// Required: true
Bans []*AccountcommonJWTBanV3 `json:"bans"`
// display name
// Required: true
DisplayName *string `json:"display_name"`
// expires in
// Required: true
ExpiresIn *int32 `json:"expires_in"`
// is comply
IsComply bool `json:"is_comply,omitempty"`
// jflgs
Jflgs int32 `json:"jflgs,omitempty"`
// namespace
// Required: true
Namespace *string `json:"namespace"`
// namespace roles
// Required: true
NamespaceRoles []*AccountcommonNamespaceRole `json:"namespace_roles"`
// permissions
// Required: true
Permissions []*AccountcommonPermissionV3 `json:"permissions"`
// platform id
PlatformID string `json:"platform_id,omitempty"`
// platform user id
PlatformUserID string `json:"platform_user_id,omitempty"`
// refresh token
// Required: true
RefreshToken *string `json:"refresh_token"`
// roles
// Required: true
Roles []string `json:"roles"`
// token type
// Required: true
TokenType *string `json:"token_type"`
// user id
// Required: true
UserID *string `json:"user_id"`
}Our user token does not contain a User ID even though there is a User ID field in the code above. In our case, the User ID will be used in the Lobby and Session browsers. Once you have filled in the above fields, store these values in the tokenConvert variable.
// parse token
reqToken := req.Headers["Authorization"]
splitToken := strings.Split(reqToken, "Bearer ")
if len(splitToken) == 1 || len(splitToken) > 2 {
log.Print("Token split \"Bearer\" and token authorization")
message := fmt.Sprintf("Invalid token.")
response := events.APIGatewayProxyResponse{StatusCode: http.StatusUnauthorized, Body: message}
return response
}
reqToken = splitToken[1]
tokenConvert, err := repository.ConvertTokenToTokenResponseV3(reqToken)
if tokenConvert == nil {
log.Print("Unable to convert token to response model :", err.Error())
response := events.APIGatewayProxyResponse{StatusCode: http.StatusUnauthorized, Body: fmt.Sprintf(err.Error())}
return response
}Validate the user tokens and permissions using the tokenConvert function. Once completed, validate the user token with custom permissions for the role inside the validatePermissionHandler function.
// validating permission using lambda function
func (titleMMService *TitleMatchmakingService) validatePermissionHandler(reqToken, clientId string,
tokenResponse *iamclientmodels.OauthmodelTokenResponseV3) (int, error) {
var namespaceRoles []iam.NamespaceRole
var permissions []iam.Permission
for _, namespaceRole := range tokenResponse.NamespaceRoles {
n := iam.NamespaceRole{
RoleID: *namespaceRole.RoleID,
Namespace: *namespaceRole.Namespace,
}
namespaceRoles = append(namespaceRoles, n)
}
log.Printf("namespaceRoles : %+v", namespaceRoles)
var rangeSchedule []string
for _, permission := range tokenResponse.Permissions {
p := iam.Permission{
Resource: *permission.Resource,
Action: int(*permission.Action),
ScheduledAction: int(permission.SchedAction),
CronSchedule: "",
RangeSchedule: rangeSchedule,
}
permissions = append(permissions, p)
}
// validate token
validateAccessToken, err := titleMMService.IamClient.ValidateAccessToken(reqToken)
if err != nil {
log.Print("Validate access token error. Token expired.", err.Error())
return http.StatusBadRequest, err
}
if !validateAccessToken {
log.Print("Validate access token return false. ", err)
return http.StatusUnauthorized, err
} else {
log.Print("Access token is a valid one.")
}
// validate permission
claims := iam.JWTClaims{
Namespace: *tokenResponse.Namespace,
DisplayName: *tokenResponse.DisplayName,
Roles: tokenResponse.Roles,
AcceptedPolicyVersion: nil,
NamespaceRoles: namespaceRoles,
Permissions: permissions,
Bans: nil,
JusticeFlags: 0,
Scope: "",
Country: "",
ClientID: clientId,
IsComply: false,
Claims: iam.JWTClaims{}.Claims,
}
resource := make(map[string]string, 0)
resource["{namespace}"] = claims.Namespace
validatePermission, err := titleMMService.IamClient.ValidatePermission(
&claims,
iam.Permission{
Resource: "NAMESPACE:{namespace}:MATCHMAKING",
Action: iam.ActionCreate,
},
resource,
)
if err != nil {
log.Print("Unable to validate permission. Error : ", err.Error())
return http.StatusForbidden, err
} else {
log.Print("Successful validate permission from iam client")
}
if !validatePermission {
log.Print("Insufficient permissions")
return http.StatusForbidden, err
} else {
log.Print("There's enough permission")
}
return http.StatusOK, nil
}Permissions will be added to this role using the user token, as per the response below.
Obtain the User ID from the subfield in the user token.
Validate Token and Get User ID
claims, err := titleMMService.IamClient.ValidateAndParseClaims(reqToken)
if claims == nil {
log.Print("Claim is empty. Error : ", err.Error())
message := "Claim is empty"
response := events.APIGatewayProxyResponse{StatusCode: http.StatusUnauthorized, Body: fmt.Sprintf(message)}
return response
}
if err != nil {
log.Print("Unable to validate and parse token. Error : ", err.Error())
response := events.APIGatewayProxyResponse{StatusCode: http.StatusUnauthorized, Body: fmt.Sprintf(err.Error())}
return response
}
userId := claims.Subject
namespace := claims.Namespace
namespaceGame := constants.NamespaceGame
gameMode := constants.GameModeStore the valid user token in the empty interface so you can get and use the valid token.
// store the valid token
errToken := tokenRepositoryImpl.Store(*tokenConvert)
if errToken != nil {
log.Print("Unable to store token :", errToken.Error())
message := fmt.Sprint("Unable to store token")
response := events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError, Body: message}
return response
}Set the OAuth Client token for the DSMC service and input the registered Client ID in the GAME_CLIENT_ID field. While the Lobby and Session browsers need a user token, the DSMC needs a client token (client_credentials from a registered OAuth Client).
// get token from game client for DSMC
log.Print("Config Repo Game Client Id : ", configGameImpl.GetClientId())
oauthService = iamServices.OAuth20Service{
IamService: factory.NewIamClient(&configGameImpl),
ConfigRepository: &configGameImpl,
TokenRepository: &tokenRepositoryGameImpl,
}
err = oauthService.GrantTokenCredentials("", "")
if err != nil {
log.Print("Unable to grant token : ", err.Error())
message := fmt.Sprint("Unable to grant token")
response := events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError, Body: message}
return response
}
tokenRepo, err := oauthService.TokenRepository.GetToken()
if err != nil {
log.Print("Empty error : ", err.Error())
message := fmt.Sprint("Unable to get token")
response := events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError, Body: message}
return response
}
if tokenRepo == nil {
log.Print("Empty tokenRepo.")
message := fmt.Sprint("Empty token repository")
response := events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError, Body: message}
return response
}
Tutorials
In this section, you will learn how to implement the services needed to build a Matchmaking service. All of the required functions are available inside the matchmaking.go files.
Create a Matchmaking Request
To create a matchmaking request, you will need a ticket that includes a process for the request, as well as preparation for a new channel and a party.
// store userId as waiting ticket and look for all users in database
createTicketErr := titleMMService.createTicket(namespace, gameMode, userId, 1, 1)
if createTicketErr != nil {
log.Print(createTicketErr)
message := "Unable to create ticket."
response := events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError, Body: message}
return response, nil
}
Set Up Waiting Time
The Matchmaking service will look for possible players and set the maximum waiting time for the service to find another player to send a Matchmaking request.
// possible allies
possibleAllies := len(allTickets)
log.Printf("There are %v tickets in database with id: %v", possibleAllies, allUsers)
if len(allUsers) <= 1 {
go func() {
b := backoff.NewExponentialBackOff()
b.MaxElapsedTime = 5 * time.Second
checkDB := func() error {
foundUserIds, _ := titleMMService.checkAllies(namespace, userId, gameMode)
if foundUserIds != nil {
allUsers = foundUserIds
}
return nil
}
err = backoff.Retry(checkDB, b)
if err != nil {
log.Fatalf("error after retrying: %v", err)
}
}()
time.Sleep(5 * time.Second)
log.Printf("Not enough player! There is only %v player", possibleAllies)
message := "Not enough player"
response := events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError, Body: message}
return response, nil
}
Set Up Searching Notifications
A search notification function can be used to display the current progress of the Matchmaking service to players while they are waiting to be matched. The sendNotificationSearching function will find the WebSocket message containing the word searching and will be sent to players via the FreeFormNotificationByUserId function from the Lobby service, which will hit the Freeform endpoint through our Cloud Golang SDK.
// GO-SDK lobby service
func sendNotificationSearching(namespace, userId string) error {
message := "searching"
topic := constants.MatchmakingNotificationTopic
body := lobbyclientmodels.ModelFreeFormNotificationRequest{
Message: &message,
Topic: &topic,
}
input := ¬ification.FreeFormNotificationByUserIDParams{
Body: &body,
Namespace: namespace,
UserID: userId,
}
gameNotificationService := lobby.NotificationService{
Client: factory.NewLobbyClient(&configImpl),
TokenRepository: &tokenRepositoryImpl,
}
//lint:ignore SA1019 Ignore the deprecation warnings
sendNotificationSearchingErr := gameNotificationService.FreeFormNotificationByUserID(input)
if sendNotificationSearchingErr != nil {
log.Printf("Unable to send notification match searching to lobby. userId : %+v", userId)
log.Print(sendNotificationSearchingErr.Error())
return sendNotificationSearchingErr
}
return nil
}
Manage Game Sessions
Create a Game Session in the Session Browser
In this section, we will be registering the game session to the session browser. This step will also check if there are enough players in the database. Once enough players have been found, the game session will be registered to the session browser and a Session ID will be created. This ID will be used to register the session in the DSMC.
// GO-SDK session browser service
func createSession(namespaceGame string) (*sessionbrowserclientmodels.ModelsSessionResponse, error) {
allowJoinInProgress := true
currentPlayer := int32(0)
maxPlayer := int32(0)
currentInternalPlayer := int32(0)
maxInternalPlayer := int32(2)
numBot := int32(0)
username := os.Getenv("SESSION_BROWSER_USERNAME")
password := os.Getenv("SESSION_BROWSER_PASSWORD")
mapName := os.Getenv("SESSION_BROWSER_MAP_NAME")
mode := os.Getenv("SESSION_BROWSER_MODE")
sessionType := os.Getenv("SESSION_BROWSER_TYPE")
gameVersion := os.Getenv("SESSION_BROWSER_GAME_VERSION")
var settings interface{}
var sessionResponse *sessionbrowserclientmodels.ModelsSessionResponse
gameSetting := sessionbrowserclientmodels.ModelsGameSessionSetting{
AllowJoinInProgress: &allowJoinInProgress,
CurrentInternalPlayer: ¤tInternalPlayer,
CurrentPlayer: ¤tPlayer,
MapName: &mapName,
MaxInternalPlayer: &maxInternalPlayer,
MaxPlayer: &maxPlayer,
Mode: &mode,
NumBot: &numBot,
Password: &password,
Settings: &settings,
}
sessionBrowserService := sessionbrowser.SessionService{
Client: factory.NewSessionbrowserClient(&configImpl),
TokenRepository: &tokenRepositoryImpl,
}
body := sessionbrowserclientmodels.ModelsCreateSessionRequest{
GameSessionSetting: &gameSetting,
GameVersion: &gameVersion,
Namespace: &namespaceGame,
SessionType: &sessionType,
Username: &username,
}
input := &session.CreateSessionParams{
Body: &body,
Namespace: namespaceGame,
}
createSessionResp, err := sessionBrowserService.CreateSession(input)
if err != nil {
log.Printf("Unable to create session. namespace : %s. Error: %v", namespaceGame, err)
return createSessionResp, err
}
if createSessionResp == nil {
log.Print("create session response is nil: ", createSessionResp)
return nil, nil
} else {
createSessionResponse := &sessionbrowserclientmodels.ModelsSessionResponse{
CreatedAt: createSessionResp.CreatedAt,
GameSessionSetting: createSessionResp.GameSessionSetting,
GameVersion: createSessionResp.GameVersion,
Joinable: createSessionResp.Joinable,
Match: createSessionResp.Match,
Namespace: createSessionResp.Namespace,
Server: createSessionResp.Server,
SessionID: createSessionResp.SessionID,
SessionType: createSessionResp.SessionType,
UserID: createSessionResp.UserID,
Username: createSessionResp.Username,
}
sessionResponse = createSessionResponse
}
return sessionResponse, nil
}
Register a Game Session to the DSMC
After successfully creating a session, the handler will register a session in the DSMC service using the OAuth Client token.
// GO-SDK session DSMC service
func registerSessionDSMC(sessionId, gameMode, namespaceGame, partyId string,
allUsers []string) (*dsmcclientmodels.ModelsSessionResponse, error) {
var partyAttributes interface{}
var matchingAllies []*dsmcclientmodels.ModelsRequestMatchingAlly
var matchingParties []*dsmcclientmodels.ModelsRequestMatchParty
var partyMembers []*dsmcclientmodels.ModelsRequestMatchMember
for _, userId := range allUsers {
partyMembers = append(partyMembers, &dsmcclientmodels.ModelsRequestMatchMember{UserID: &userId})
}
matchingParty := dsmcclientmodels.ModelsRequestMatchParty{
PartyAttributes: partyAttributes,
PartyID: &partyId,
PartyMembers: partyMembers,
}
matchingParties = append(matchingParties, &matchingParty)
matchingAlly := dsmcclientmodels.ModelsRequestMatchingAlly{MatchingParties: matchingParties}
matchingAllies = append(matchingAllies, &matchingAlly)
clientVersion := ""
configuration := ""
deployment := os.Getenv("DSMC_DEPLOYMENT")
podName := ""
region := ""
dsmcService := dsmc.SessionService{
Client: factory.NewDsmcClient(&configGameImpl),
TokenRepository: oauthService.TokenRepository,
}
body := dsmcclientmodels.ModelsCreateSessionRequest{
ClientVersion: &clientVersion,
Configuration: &configuration,
Deployment: &deployment,
GameMode: &gameMode,
MatchingAllies: matchingAllies,
Namespace: &namespaceGame,
PodName: &podName,
Region: ®ion,
SessionID: &sessionId,
}
input := &dsmcSession.CreateSessionParams{
Body: &body,
Namespace: namespaceGame,
}
registerSession, registerSessionErr := dsmcService.CreateSession(input)
if registerSessionErr != nil {
log.Print(registerSessionErr)
}
return registerSession, nil
}
Claim a Game Server
This function is used to claim a game server from the DSMC service.
// GO-SDK DSMC service
func claimServer(namespaceGame string, sessionID *string) error {
dsmcService := dsmc.SessionService{
Client: factory.NewDsmcClient(&configGameImpl),
TokenRepository: oauthService.TokenRepository,
}
body := dsmcclientmodels.ModelsClaimSessionRequest{SessionID: sessionID}
input := &dsmcSession.ClaimServerParams{
Body: &body,
Namespace: namespaceGame,
}
claimServerErr := dsmcService.ClaimServer(input)
if claimServerErr != nil {
log.Print(claimServerErr)
}
return nil
}
Get Server Information
This function retrieves the server's information based on a specific session and displays this information in the Matchmaking log.
// GO-SDK DSMC service
func getServer(namespaceGame, sessionID string) (*dsmcclientmodels.ModelsSessionResponse, error) {
dsmcService := dsmc.SessionService{
Client: factory.NewDsmcClient(&configGameImpl),
TokenRepository: oauthService.TokenRepository,
}
input := &dsmcSession.GetSessionParams{
Namespace: namespaceGame,
SessionID: sessionID,
}
getSession, getSessionErr := dsmcService.GetSession(input)
if getSessionErr != nil {
log.Print(getSessionErr)
}
if getSession == nil {
log.Print("get session server from DSMC service is nil")
}
return getSession, nil
}
Add Players to the Game Session
This function adds players to the server in the Session browser. In this function, we use a loop in our code to add players and match allies in the Matchmaking session.
// GO-SDK session browser service
func addPlayer(namespaceGame, userId, sessionId string) (*sessionbrowserclientmodels.ModelsAddPlayerResponse, error) {
asSpectators := false
body := sessionbrowserclientmodels.ModelsAddPlayerRequest{
AsSpectator: &asSpectators,
UserID: &userId,
}
input := &session.AddPlayerToSessionParams{
Body: &body,
Namespace: namespaceGame,
SessionID: sessionId,
}
sessionBrowserService := sessionbrowser.SessionService{
Client: factory.NewSessionbrowserClient(&configImpl),
TokenRepository: &tokenRepositoryImpl,
}
addPlayerResp, addPlayerErr := sessionBrowserService.AddPlayerToSession(input)
if addPlayerErr != nil {
log.Printf("Unable to add player to session id %v. namespace : %s. Error: %v", sessionId, namespaceGame, addPlayerErr)
return addPlayerResp, addPlayerErr
}
if addPlayerResp == nil {
log.Print("add player response is nil: ", addPlayerResp)
return nil, nil
}
log.Printf("Successfully add player. userId: %v. sessionId: %v, namespace: %v", userId, sessionId, namespaceGame)
return addPlayerResp, nil
}
Get Session Update
After adding players to the Matchmaking session, create a function to obtain Matchmaking session updates by a specific Session ID. This function will find when the session was created, its joinable status, and other session parameters.
// GO-SDK session browser service
func getSessionUpdate(namespaceGame, sessionId string) (*sessionbrowserclientmodels.ModelsSessionResponse, error) {
sessionBrowserService := sessionbrowser.SessionService{
Client: factory.NewSessionbrowserClient(&configImpl),
TokenRepository: &tokenRepositoryImpl,
}
input := &session.GetSessionParams{
Namespace: namespaceGame,
SessionID: sessionId,
}
getSession, getSessionErr := sessionBrowserService.GetSession(input)
if getSessionErr != nil {
log.Print(getSessionErr)
return getSession, getSessionErr
}
if getSession == nil {
log.Print("get session response is nil: ", getSession)
} else {
getSessionResponse := &sessionbrowserclientmodels.ModelsSessionResponse{
CreatedAt: getSession.CreatedAt,
GameSessionSetting: getSession.GameSessionSetting,
GameVersion: getSession.GameVersion,
Joinable: getSession.Joinable,
Match: getSession.Match,
Namespace: getSession.Namespace,
Server: getSession.Server,
SessionID: getSession.SessionID,
SessionType: getSession.SessionType,
UserID: getSession.UserID,
Username: getSession.Username,
}
getSession = getSessionResponse
}
log.Printf("Successfully get session update : %+v", *getSession)
return getSession, nil
}
Set Up a Match Notification
This function sends a notification to the player when they successfully find a match.
// GO-SDK lobby service
func sendNotificationFound(
namespace,
IP string,
port int32,
allUsers []string) (bool, error) {
topic := constants.MatchmakingNotificationTopic
gameNotificationService := lobby.NotificationService{
Client: factory.NewLobbyClient(&configImpl),
TokenRepository: &tokenRepositoryImpl,
}
messageIPPort := fmt.Sprintf("found %v %v", IP, port)
body := lobbyclientmodels.ModelFreeFormNotificationRequest{
Message: &messageIPPort,
Topic: &topic,
}
for _, userIdToSend := range allUsers {
input := ¬ification.FreeFormNotificationByUserIDParams{
Body: &body,
Namespace: namespace,
UserID: userIdToSend,
}
sendNotificationMatchFoundErr := gameNotificationService.FreeFormNotificationByUserID(input)
if sendNotificationMatchFoundErr != nil {
log.Print(sendNotificationMatchFoundErr)
return false, sendNotificationMatchFoundErr
}
log.Printf("Match found! Successfully send notification to userId : %+v", userIdToSend)
}
return true, nil
}
Testing the Matchmaking
In this section, you will learn how to run and test your Matchmaking service locally.
Inside the AccelByte Cloud Golang SDK repository, open the samples/title-matchmaking/Client folder.
To test the Matchmaking service, prepare two test player emails and passwords.
Open the terminal and go to the title-matchmaking repo directory.
In your terminal, run the following command and fill in the following environment variables:
Input the Client ID, Client Secret, and BaseUrl with the value set in your OAuth Client in the Admin Portal.
Define the Create Matchmaking API URL with the AWS Lambda API Gateway. For testing purposes, you can use API Gateway's TestInvoke feature.
$ export APP_CLIENT_ID=<user_secret_id>
$ export APP_CLIENT_SECRET=""
$ export JUSTICE_BASE_URL="<iam_url>"
$ export CREATE_MATCHMAKING_ENDPOINT="<endpoint>"Once completed, press Enter.
:::tip TIP If you need to check the environment value at a later stage, use the following command: $ echo $APP_CLIENT_ID $ echo $APP_CLIENT_SECRET $ echo $JUSTICE_BASE_URL :::
To run the matchmaking, use the command go run main.go to bring up the matchmaking command.
Commands :
# PoC Matchmaking
1: Login
2: Create Matchmaking
3: User info
4: LogoutEnter command 1 to log the player into the matchmaking. Enter the player's email and password.
Enter command 2 to bring players online for the matchmaking. In order to make a successful match, both players must be online. This command will call the Lambda function and listen to the notification service to search for a match. A notification will be sent when a match is found.
The returned response contains the IP and port:
Below is an example of what your screen will look like from the allies' side once a match has been found. Both players must be online for the match to succeed; otherwise, an error notification will be sent.
Deploying the Matchmaking Function into AWS Lambda
To upload the Matchmaking function that you created to AWS Lambda, first ensure that you have filled in all of the required fields in the AWS SAM Template and have tested the SAM locally both in AWS SAM and Client.
In your terminal, go to the root directory aws-lambda.
Build the executable binary file in the main.go by running the command:
go build main.go
Once the build is complete, zip the executable binary file to main.zip.
Upload the main.zip file into the configured AWS Lambda console.
Once complete, call the Lambda API Gateway URL using a user token.