Skip to main content

Introduction to the Matchmaking function

Last updated on April 17, 2024
info

Extend is in Open Beta for AGS Premium Clients! This means that the Extend add-on is available for you to try in your development environment. You can submit your feedback via our Extend Open Beta feedback form.

Overview

In this guide, we will go one step further than the Simple Sample App and create a Free-For-All (FFA) matchmaker by customizing the following functions:

  • EnrichTicket
  • ValidateTicket
  • MakeMatches

Match Rules

For our FFA MatchMaker, we’ll use a set of match rules known as GameRules, which will need to be created and registered to the MatchPool via the Admin Portal. Here is what the GameRules looks like:

{
"alliance": {
"min_number": 1,
"max_number": 1,
"player_min_number": 5,
"player_max_number": 10
}
}

These rules are saying that the minimum amount of players needed for this FFA is five and the maximum amount of players is 10.

EnrichTicket

EnrichTicket is called before ValidateTicket and is meant to be used for adding additional logic to the match ticket. In this example we’ll add a float64 to the ticket’s TicketAttributes that will represent a spawnLocation to use later on.

note

EnrichTicket is a great place to add external data by making network calls during this part of the flow.

func (g GameMatchMaker) EnrichTicket(matchTicket matchmaker.Ticket, ruleSet interface{}) (ticket matchmaker.Ticket, err error) {
rand.Seed(uint64(time.Now().UnixNano()))
var num float64
enrichMap := map[string]interface{}{}
if len(matchTicket.TicketAttributes) == 0 {
num = float64(rand.Intn(100-0+1) + 0)
enrichMap["spawnLocation"] = math.Round(num)
matchTicket.TicketAttributes = enrichMap
} else {
num = float64(rand.Intn(100-0+1) + 0)
matchTicket.TicketAttributes["spawnLocation"] = math.Round(num)
}
return matchTicket, nil
}

ValidateTicket

ValidateTicket is called next and returns a bool if the match ticket is valid or not. First, we’ll check if the amount of players on the match ticket is not greater than the PlayerMaxNumber defined in our GameRules. Next we’ll check if the spawnLocation is a non-nil float64 value.

func (g GameMatchMaker) ValidateTicket(matchTicket matchmaker.Ticket, matchRules interface{}) (bool, error) {
rules, ok := matchRules.(GameRules)
if !ok {
return false, errors.New("invalid rules type for game rules")
}

if len(matchTicket.Players) > rules.AllianceRule.PlayerMaxNumber {
return false, errors.New(fmt.Sprintf("too many players on the ticket, max is %d", rules.AllianceRule.PlayerMaxNumber))

}

return true, nil
}

MakeMatches

Now that we have the matchTicket decorated and validated, let's start making some matches! There are many ways to create a matchmaker logic; some can be rather simplistic, others require a more complex approach. Our overall goal for this matchmaker is to have the match we create have the minimum amount of players while not exceeding the maximum amount, but attempt to fill to the maximum amount.

We can land somewhere in the middle of simplistic and complex (while still having room to grow). For this, we’ll create a queue system using buckets. Each bucket’s index will be the amount of players on the ticket, so when we want to look up an amount of players needed, we can pop from the queue. Here’s what our queue will look like.

Queue

Our queue will contain a slice of matchmaker.Ticket type, along with pop and push functions. push will simply append to the queue’s tickets, while pop will return and remove a ticket. Order won’t matter here since each queue has the same amount of Players on each ticket.

type queue struct {
tickets []matchmaker.Ticket
lock sync.Mutex
}

func (q *queue) push(ticket matchmaker.Ticket) {
q.lock.Lock()
defer q.lock.Unlock()
q.tickets = append(q.tickets, ticket)
}

func (q *queue) pop() *matchmaker.Ticket {
if len(q.tickets) == 0 {
return nil
}
q.lock.Lock()
defer q.lock.Unlock()
ticket := q.tickets[0]
q.tickets = q.tickets[1:]
return &ticket
}

buildGame

We’ll use need a function to house our logic that will go inside of MakeMatches.

Our logic needs to be able to achieve our goal above. First, we’ll want to put our unmatchedTickets into the queues by checking if we have one already – if we don't, create a bucket where the key is the amount of players on the ticket and the value will be the queue, which we will push it into.

Next, we’ll use an outer loop to get a rootTicket to start from. To do this we use another function called nextTicket that will sort the keys – we’ll take the largest amount of players that don’t exceed PlayerMaxNumber from our GameRules.

Now that we have our rootTicket, we want to know how many tickets we need to reach the PlayerMaxNumber. We’ll find the difference (remainingPlayerCount) and start an inner loop that will go through the rest of the tickets in the queues (searching through each bucket by the remainingPlayerCount) to see if we can find a ticket that has the amount of players we need. If nothing is found, we’ll decrement and continue until we’ve been through all of the tickets and we have at least the PlayerMinNumber, or our PlayerMaxNumber is reached.

func buildGame(unmatchedTickets []matchmaker.Ticket, results chan matchmaker.Match, gameRules GameRules) {
defer close(results)
max := gameRules.AllianceRule.PlayerMaxNumber
min := gameRules.AllianceRule.PlayerMinNumber
buckets := map[int]*queue{}
for _, ticket := range unmatchedTickets {
bucket, ok := buckets[len(ticket.Players)]
if !ok {
bucket = newQueue()
buckets[len(ticket.Players)] = bucket
}
bucket.push(ticket)
}

//start outer loop
for {
rootTicket := nextTicket(buckets, max)
if rootTicket == nil {
return
}
remainingPlayerCount := max - len(rootTicket.Players)

matchedTickets := []matchmaker.Ticket{*rootTicket}

//start inner loop
for {
if remainingPlayerCount == 0 {
break
}
otherTicket := nextTicket(buckets, remainingPlayerCount)
if otherTicket == nil {
if remainingPlayerCount >= min {
break
}
return
}
matchedTickets = append(matchedTickets, *otherTicket)
remainingPlayerCount -= len(otherTicket.Players)

}

ffaTeam := mapPlayerIDs(matchedTickets)
match := matchmaker.Match{Tickets: matchedTickets,
Teams: []matchmaker.Team{{UserIDs: ffaTeam}}}
results <- match
}
}

nextTicket

func nextTicket(buckets map[int]*queue, maxPlayerCount int) *matchmaker.Ticket {
bucketKeys := maps.Keys(buckets)
sort.Ints(bucketKeys)

for i := len(bucketKeys) - 1; i >= 0; i-- {
if bucketKeys[i] > maxPlayerCount {
continue
}
ticket := buckets[bucketKeys[i]].pop()
if ticket != nil {
return ticket
}
}
return nil
}

mapPlayerIDs

func mapPlayerIDs(tickets []matchmaker.Ticket) []player.ID {
playerIDs := []player.ID{}
for _, ticket := range tickets {
for _, p := range ticket.Players {
playerIDs = append(playerIDs, p.PlayerID)
}
}
return playerIDs
}

MakeMatches continued

Now that our logic is all squared-away, lets finish setting up MakeMatches. First we’ll check that our rules are GameRules and then call our GetTickets() function that will get all of the tickets from the MatchPool’s Database. Next we’ll call our buildGame function and return the results go-channel once all of the matches are finished being made.

func (g GameMatchMaker) MakeMatches(ticketProvider TicketProvider, matchRules interface{}) <-chan matchmaker.Match {
results := make(chan matchmaker.Match)
rules, ok := matchRules.(GameRules)
if !ok {
return results
}

go func() {
var unmatchedTickets []matchmaker.Ticket
tickets := ticketProvider.GetTickets()
for ticket := range tickets {
unmatchedTickets = append(unmatchedTickets, ticket)
}
go buildGame(unmatchedTickets, results, rules)
}()

return results
}