Open Match is a flexible and scalable open-source matchmaking framework. It consists of a ticketing-system, each ticket representing a player in a queue, and an empty matchmaking function, which needs to be defined by the developer. Open Match is an easy way for game studios to implement a fast and reliable infrastructure to manage their matchmaking queue.
The Idem Self-hosted container can easily be integrated with Open Match. This is the fastest and most reliable way for customers to deploy Idem's state-of-the-art matchmaker on their own infrastructure. In this setup, Open Match manages the queue-state and whenever Open Match's matchmaking function is called, it sends the entire queue-state to Idem's container, and receives back the matches.
The integration is done in these four steps:
- Configure each Open Match ticket to include Idem
ticketparameters and the identifier for the rightMatch Profile. - Define an Open Match
Match Profilethat includes the desiredgameModeconfiguration for Idem Self-hosted for each of your game modes. - Replace the Open Match
MatchFunctionwith one interacting with the Idem container - Configure your Open Match
Directorto consume the Open MatchMatcheswith the additional fields provided by Idem
The following sections guide you through the process step-by-step. This is aligned along the Open Match tutorial How to build a basic Matchmaker. If you are not familiar with Open Match we recommend you chat that out first.
1. Configuring Open Match tickets for Idem
As each ticket for the Idem matchmaker requires a rating and compatible servers, this information needs to be added to each Open Match ticket.
Additionally, each ticket will have its gameMode assigned.
You can learn more about these here.
All three of these are put into the SearchFields of the Open Match ticket.
The example below shows a simple ticket generator implementing random values for all three.
package main
import (
"math/rand"
"strings"
"open-match.dev/open-match/pkg/pb"
)
func makeTicket() *pb.Ticket {
modes := []string{"1v1", "2v2"}
servers := []string{"eu-central-1", "eu-west-1", "eu-east-1"}
rating := float64(1300 + rand.Intn(1700-1300+1))
rand.Shuffle(len(servers), func(i, j int) {
servers[i], servers[j] = servers[j], servers[i]
})
selectedServers := servers[:2]
serversStr := strings.Join(selectedServers, ",")
return &pb.Ticket{
SearchFields: &pb.SearchFields{
DoubleArgs: map[string]float64{
"rating": rating,
},
StringArgs: map[string]string{
"gameMode": modes[rand.Intn(len(modes))],
"servers": serversStr,
},
},
}
}
For the advanced matchmaker, you can optionally replace the server list by a map of servers to latencies. See here for more detail.
2. Defining match profiles for Idem in Open Match
In order to get the right kind of matches for your tickets, the Open Match MatchFunction needs to know about the desired game_mode configuration. You also need to be able to separate out tickets for different game modes. This is achieved by configuring different Match Profiles that contain the relevant information. You need to create a profile for each gameMode you want to be able to assign to your tickets.
The code below shows a simple example for for the Community Edition matchmaker. It will generate two profiles, one for a 1v1 mode and one for a 2v2 mode.
Specifically, it sets this information for each profile:
numTeamsnumPlayersPerTeammaxWaitingTime
The profileName is what should match the game_mode of your tickets.
package main
import (
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
"open-match.dev/open-match/pkg/pb"
)
func mustAnyIntValue(n int) *anypb.Any {
a, err := anypb.New(&wrapperspb.Int32Value{Value: int32(n)})
if err != nil {
panic(err)
}
return a
}
func createMatchProfile(profileName string, numTeams, numPlayersPerTeam, maxWaitingTime int) *pb.MatchProfile {
poolName := "pool_" + profileName
return &pb.MatchProfile{
Name: profileName,
Pools: []*pb.Pool{
{
Name: poolName,
StringEqualsFilters: []*pb.StringEqualsFilter{
{StringArg: "gameMode", Value: profileName},
},
},
},
Extensions: map[string]*anypb.Any{
"numTeams": mustAnyIntValue(numTeams),
"numPlayersPerTeam": mustAnyIntValue(numPlayersPerTeam),
"maxWaitingTime": mustAnyIntValue(maxWaitingTime),
},
}
}
func generateProfiles() []*pb.MatchProfile {
var profiles []*pb.MatchProfile
profiles = append(profiles, createMatchProfile("1v1", 2, 1, 30))
profiles = append(profiles, createMatchProfile("2v2", 2, 2, 45))
return profiles
}
3. Idem-compatible MatchFunction
The Open Match MatchFunction is what handles the actual matchmaking. For integrating with the Idem container, the function needs to perfrom three key tasks:
- Convert the Open Match
ticketsinto the Idem ticket format - Send the matchmaking request to the Idem container and parse the response
- Convert the Idem
matchSuggestionsto Open Matchmatches
The code below shows a full implementation of a MatchFunction fulfilling these criteria.
For any matched ticket, the following updates will be applied based on the response of the Idem matchmaker
- The
serversStringArgwill be set to the selected server - An new
StringArgteamwill be set to the team the ticket was put into
If you want to use this function, don't forget to replace the baseURL in const with the URL of the Idem container. Should you run the container locally in docker, there is a good chance that http://host.docker.internal:8000 is the value you want to set.
package mmf
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"google.golang.org/protobuf/types/known/wrapperspb"
"open-match.dev/open-match/pkg/matchfunction"
"open-match.dev/open-match/pkg/pb"
)
const (
matchFunctionName = "idem-matchmaking"
baseURL = "<address of the Idem container>"
)
type IdemTicket struct {
TicketId string `json:"ticketId"`
JoinTimestamp int64 `json:"joinTimestamp"`
Rating float64 `json:"rating"`
Servers []string `json:"servers"`
}
func convertOpenMatchTicketsToIdemTickets(tickets []*pb.Ticket) []*IdemTicket {
var idemTickets []*IdemTicket
for _, ticket := range tickets {
if len(idemTickets) >= 1000 {
break
}
idemTickets = append(idemTickets, &IdemTicket{
TicketId: ticket.Id,
JoinTimestamp: ticket.CreateTime.Seconds,
Rating: ticket.SearchFields.DoubleArgs["rating"],
Servers: strings.Split(ticket.SearchFields.StringArgs["servers"], ","),
})
}
return idemTickets
}
type IdemGameMode struct {
NumTeams int `json:"numTeams"`
NumPlayersPerTeam int `json:"numPlayersPerTeam"`
MaxWaitingTime int `json:"maxWaitingTime"`
}
type IdemMatchmakingRequest struct {
GameMode IdemGameMode `json:"gameMode"`
Tickets []*IdemTicket `json:"tickets"`
}
func createIdemMatchmakingRequest(p *pb.MatchProfile, idemTickets []*IdemTicket) (*IdemMatchmakingRequest, error) {
var numTeams wrapperspb.Int32Value
if err := p.Extensions["numTeams"].UnmarshalTo(&numTeams); err != nil {
return nil, fmt.Errorf("failed to unmarshal numTeams: %v", err)
}
var numPlayersPerTeam wrapperspb.Int32Value
if err := p.Extensions["numPlayersPerTeam"].UnmarshalTo(&numPlayersPerTeam); err != nil {
return nil, fmt.Errorf("failed to unmarshal numPlayersPerTeam: %v", err)
}
var maxWaitingTime wrapperspb.Int32Value
if err := p.Extensions["maxWaitingTime"].UnmarshalTo(&maxWaitingTime); err != nil {
return nil, fmt.Errorf("failed to unmarshal numPlayersPerTeam: %v", err)
}
return &IdemMatchmakingRequest{
GameMode: IdemGameMode{
NumTeams: int(numTeams.Value),
NumPlayersPerTeam: int(numPlayersPerTeam.Value),
MaxWaitingTime: int(maxWaitingTime.Value),
},
Tickets: idemTickets,
}, nil
}
type MatchmakingResponse struct {
MatchSuggestions []MatchSuggestion `json:"matchSuggestions"`
}
type MatchSuggestion struct {
Id string `json:"id"`
Server string `json:"server"`
Teams []TeamSuggestion `json:"teams"`
}
type TeamSuggestion struct {
Id string `json:"id"`
Tickets []TicketSuggestion `json:"tickets"`
}
type TicketSuggestion struct {
TicketId string `json:"ticketId"`
}
func executeIdemMatchmaking(client *http.Client, reqData *IdemMatchmakingRequest) (*MatchmakingResponse, error) {
jsonData, err := json.Marshal(reqData)
if err != nil {
return nil, fmt.Errorf("error converting matchData to JSON: %w", err)
}
url := baseURL + "/matchmaking/simple"
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("received non-OK response: %s", resp.Status)
}
var response MatchmakingResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("error decoding response JSON: %w", err)
}
return &response, nil
}
func extractOpenMatchMatchesFromIdemResponse(response *MatchmakingResponse, tickets []*pb.Ticket, p *pb.MatchProfile) []*pb.Match {
ticketMap := make(map[string]*pb.Ticket)
for _, ticket := range tickets {
ticketMap[ticket.Id] = ticket
}
var matches []*pb.Match
for _, suggestion := range response.MatchSuggestions {
var matchTickets []*pb.Ticket
for _, team := range suggestion.Teams {
for _, tData := range team.Tickets {
if origTicket, ok := ticketMap[tData.TicketId]; ok {
origTicket.SearchFields.StringArgs["servers"] = suggestion.Server
origTicket.SearchFields.StringArgs["team"] = team.Id
matchTickets = append(matchTickets, origTicket)
}
}
}
matches = append(matches, &pb.Match{
MatchId: suggestion.Id,
MatchProfile: p.GetName(),
MatchFunction: matchFunctionName,
Tickets: matchTickets,
})
}
return matches
}
func makeMatches(p *pb.MatchProfile, poolTickets map[string][]*pb.Ticket) ([]*pb.Match, error) {
var allMatches []*pb.Match
client := &http.Client{Timeout: 10 * time.Second}
for _, tickets := range poolTickets {
idemTickets := convertOpenMatchTicketsToIdemTickets(tickets)
matchmakingReq, err := createIdemMatchmakingRequest(p, idemTickets)
if err != nil {
return nil, err
}
response, err := executeIdemMatchmaking(client, matchmakingReq)
if err != nil {
return nil, err
}
matches := extractOpenMatchMatchesFromIdemResponse(response, tickets, p)
allMatches = append(allMatches, matches...)
}
return allMatches, nil
}
func (s *MatchFunctionService) Run(req *pb.RunRequest, stream pb.MatchFunction_RunServer) error {
log.Printf("Generating proposals for function %v", req.GetProfile().GetName())
poolTickets, err := matchfunction.QueryPools(stream.Context(), s.queryServiceClient, req.GetProfile().GetPools())
if err != nil {
log.Printf("Failed to query tickets for the given pools, got %s", err.Error())
return err
}
proposals, err := makeMatches(req.GetProfile(), poolTickets)
if err != nil {
log.Printf("Failed to generate matches, got %s", err.Error())
return err
}
log.Printf("Streaming %v proposals to Open Match", len(proposals))
for _, proposal := range proposals {
if err := stream.Send(&pb.RunResponse{Proposal: proposal}); err != nil {
log.Printf("Failed to stream proposals to Open Match, got %s", err.Error())
return err
}
}
return nil
}
Remember to update the code for convertOpenMatchTicketsToIdemTickets if you want to use a latency map instead of a server list.
4. Configuring the Director for the Idem-based matches
As described in the previous section, for each ticket in a match the StringArg will include the selected server location for the match in servers and the team the ticket was assigned to in team.
You should use this information in your Director to request the appropriate server and set up the teams for the game.
That's it. If you have any questions, reach out to us any time.
