Introduction to the Matchmaking function (advanced)
Overview
In this guide, we will go one step further than the app template 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.
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
}