Skip to content

Repository files navigation

flexi

Go Reference

flexi is an in-memory matchmaking library for Go that is compatible with Amazon GameLift FlexMatch rule sets.

It accepts the same JSON rule set you would pass to AWS's CreateMatchmakingRuleSet API and evaluates tickets locally — no GameLift hosting, no AWS connectivity, no networking. Drop the rule set in, enqueue tickets, drive the matchmaker, get matches.

Features

  • AWS-compatible JSON: feed the same rule set documented in the FlexMatch developer guide, including the AWS property-expression dialect (teams[red].players.attributes[skill]). ruleLanguageVersion is required and must be "1.0".
  • All eight rule kinds: comparison, distance, absoluteSort, distanceSort, batchDistance, collection, latency, compound.
  • All four player attribute types: string, number, string_list, string_number_map, with default values applied to players that omit an attribute. A value whose kind disagrees with the declared type is rejected at Enqueue; undeclared attributes pass through.
  • Property-expression aggregations: min, max, avg, median, sum, count, stddev, flatten, set_intersection, with per-team nesting (avg(teams[*].players.attributes[skill]) → one result per team).
  • partyAggregation: min / max / avg (and union / intersection for collection) on multi-player tickets.
  • Compound statements: AWS string form with and / or / not / xor, e.g. "or(and(A,B), not(C))".
  • Algorithm block: exhaustiveSearch and balanced strategies, batchingPreference (random / sorted / largestPopulation / fastestRegion), sortByAttributes, backfillPriority, expansionAgeSelection.
  • Backfill: EnqueueBackfill fills the empty seats of a match already under way — the standalone counterpart of StartMatchBackfill — seating the players you report as still in the session and matching new ones around them. backfillPriority (high / low / normal) decides when matchmaking reaches for those requests.
  • Expansions: rule values, team sizes (teams[Red,Blue].minPlayers), and algorithm fields loosen automatically as tickets wait.
  • Ticket status & player acceptance: FlexMatch-compatible lifecycle (QUEUEDREQUIRES_ACCEPTANCEPLACINGCOMPLETED, plus SEARCHING / CANCELLED / TIMED_OUT) driven by acceptanceRequired / acceptanceTimeoutSeconds / requestTimeoutSeconds on the rule set. On a failed acceptance (reject or acceptance timeout) the tickets that did accept return to SEARCHING for re-matching while the rest are CANCELLED; TIMED_OUT is reserved for the request-level requestTimeoutSeconds, mirroring AWS.
  • Injectable clock: tests advance time deterministically, no time.Sleep.
  • Zero external dependencies at runtime (testify is test-only). No network or persistence.
  • Goroutine-safe: producers may Enqueue / Cancel / Accept / Reject while another goroutine drives Tick.

Automatic backfill (BackfillMode: "AUTOMATIC") is out of scope — AWS itself does not offer it in standalone mode. Manual backfill is supported; see Backfill.

Installation

go get github.com/moepig/flexi

Requires Go 1.26 or later.

Quick start

package main

import (
    "fmt"
    "github.com/moepig/flexi"
)

const ruleset = `{
  "name": "skill-balance",
  "ruleLanguageVersion": "1.0",
  "playerAttributes": [{"name": "skill", "type": "number"}],
  "teams": [
    {"name": "red",  "minPlayers": 2, "maxPlayers": 2},
    {"name": "blue", "minPlayers": 2, "maxPlayers": 2}
  ],
  "rules": [
    {
      "name": "FairSkill",
      "type": "distance",
      "measurements": ["avg(teams[red].players.attributes[skill])"],
      "referenceValue": "avg(teams[blue].players.attributes[skill])",
      "maxDistance": 10
    }
  ]
}`

func main() {
    mm, err := flexi.New([]byte(ruleset))
    if err != nil {
        panic(err)
    }

    for i, skill := range []float64{50, 52, 49, 51} {
        _ = mm.Enqueue(flexi.Ticket{
            ID: fmt.Sprintf("t%d", i),
            Players: []flexi.Player{{
                ID:         fmt.Sprintf("p%d", i),
                Attributes: flexi.Attributes{"skill": flexi.Number(skill)},
            }},
        })
    }

    matches, _ := mm.Tick()
    for _, m := range matches {
        fmt.Println("red:",  m.Teams["red"])
        fmt.Println("blue:", m.Teams["blue"])
    }
}

Driving the matchmaker

Matchmaker does not start any goroutines or timers — the caller drives it by invoking Tick(). This keeps tests deterministic and lets you integrate with whatever scheduling, observability, and shutdown story you already have.

ticker := time.NewTicker(time.Second)
defer ticker.Stop()

for range ticker.C {
    matches, err := mm.Tick()
    if err != nil {
        log.Printf("flexi tick: %v", err)
        continue
    }
    for _, m := range matches {
        dispatch(m) // hand off to your game server
    }
}

Tick returns every match that can be formed at this moment. Tickets consumed by a returned match are removed from the queue atomically; everything else stays queued for a future tick.

Time and expansions

Anything time-dependent — most importantly the FlexMatch expansions block — reads the current time through a Clock. The default is the system clock; tests should pass WithClock(NewFakeClock(...)) so they can advance time without sleeping.

clock := flexi.NewFakeClock(time.Now())
mm, _ := flexi.New(rulesetJSON, flexi.WithClock(clock))

mm.Enqueue(ticket)
matches, _ := mm.Tick()           // before any expansion step

clock.Advance(60 * time.Second)
matches, _ = mm.Tick()            // expansion steps with waitTimeSeconds<=60 applied

A richer rule set example

{
  "name": "example",
  "ruleLanguageVersion": "1.0",
  "playerAttributes": [
    {"name": "skill", "type": "number"},
    {"name": "modes", "type": "string_list"}
  ],
  "algorithm": {
    "strategy": "balanced",
    "balancedAttribute": "skill"
  },
  "teams": [
    {"name": "team", "minPlayers": 3, "maxPlayers": 3, "quantity": 2}
  ],
  "rules": [
    {
      "name": "FairSkill",
      "type": "distance",
      "measurements": ["avg(teams[team_1].players.attributes[skill])"],
      "referenceValue": "avg(teams[team_2].players.attributes[skill])",
      "maxDistance": 10
    },
    {
      "name": "ModeOverlap",
      "type": "collection",
      "measurements": ["set_intersection(players.attributes[modes])"],
      "operation": "reference_intersection_count",
      "referenceValue": ["TDM", "CTF", "FFA"],
      "minCount": 1
    },
    {"name": "Ping", "type": "latency", "maxLatency": 150},
    {
      "name": "All",
      "type": "compound",
      "statement": "and(FairSkill, ModeOverlap, Ping)"
    }
  ],
  "expansions": [
    {
      "target": "rules[FairSkill].maxDistance",
      "steps": [
        {"waitTimeSeconds": 30, "value": 50},
        {"waitTimeSeconds": 60, "value": 200}
      ]
    }
  ]
}

FlexMatch compliance notes

A few rule types have subtle AWS semantics that flexi follows deliberately:

  • collection operations
    • contains — counts how many times the reference value appears across the (flattened) measurement and bounds that count with minCount/maxCount (e.g. "no more than 5 medics in a match"). With no bound it just requires the value to be present.
    • intersection — counts the values shared by every player's collection and takes no referenceValue (e.g. "all players share at least one game mode", minCount: 1).
    • reference_intersection_count — requires each player's collection to intersect the referenceValue collection within minCount/maxCount. The referenceValue may be a literal array or a property expression such as set_intersection(flatten(teams[*].players.attributes[preferredOpponents])).
  • batchDistance — a numeric attribute is grouped by spread (maxDistance); a string attribute is grouped by value equivalency, where the distance is (distinct values) - 1. A string batchDistance with no maxDistance therefore requires every player to share the same value (the AWS "SameGameMode" form). (minDistance is a distance-rule property and is not accepted on batchDistance.)
  • maxDistance / minDistance accept either a JSON number (500) or a string-encoded number ("500"); the AWS docs use both forms.

Ticket status and player acceptance

Every ticket has a FlexMatch-compatible TicketStatus, queryable with Matchmaker.Status(id):

Enqueue                        → QUEUED
Tick (acceptanceRequired=false) → PLACING                           (Match returned)
Tick (acceptanceRequired=true)  → REQUIRES_ACCEPTANCE               (Proposal created)
  all Accept, then Tick        → PLACING                           (Match returned)
  Cancel                       → CANCELLED                         (whole proposal)
  any Reject / acceptance      → rejecter/non-responder CANCELLED;
    timeout                      fully-accepted siblings SEARCHING (re-queued)
requestTimeoutSeconds elapsed  → TIMED_OUT
MarkCompleted (from PLACING)   → COMPLETED

SEARCHING marks a ticket that accepted a proposed match which then failed to gather every required acceptance: it is returned to the queue and re-matched by the next Tick. TIMED_OUT is reached only when a ticket exceeds the rule set's requestTimeoutSeconds while searching — an acceptance failure (reject or acceptance timeout) terminates the non-accepting tickets as CANCELLED, matching AWS FlexMatch. FAILED is defined for parity with the AWS API but is not produced by the current implementation.

Because this library operates as FlexMatch standalone (no game-session placement), the terminal success status Tick assigns is PLACING. Promote a ticket to COMPLETED with MarkCompleted(id) once your own placement pipeline has attached connection information.

Evicting terminal state

A ticket that leaves the queue keeps its status, its cumulative rule metrics, and any claim it holds on a game session's backfill slot, so Status(id) still answers after the ticket is CANCELLED, TIMED_OUT, PLACING, or COMPLETED. Nothing releases that state on its own. A long-running process calls Matchmaker.Evict(id) once it has reported the outcome, which keeps memory bounded by the live queue rather than by every ticket ever submitted:

mm.Evict("a")       // forgets status, metrics, and session bookkeeping
mm.Status("a")      // ErrUnknownTicket
mm.Enqueue(solo("a")) // the ID is free again

A ticket still in matchmaking (QUEUED, SEARCHING, REQUIRES_ACCEPTANCE) is refused with ErrTicketNotTerminal; Cancel it first. Evicting a PLACING ticket forgoes promoting it to COMPLETED.

Enable the acceptance flow by setting the two standard FlexMatch fields on the rule set:

{
  ...
  "acceptanceRequired": true,
  "acceptanceTimeoutSeconds": 60
}

Then drive the extra state machine:

matches, _ := mm.Tick()            // acceptanceRequired=true → no matches yet
for _, p := range mm.PendingAcceptances() {
    for _, id := range p.TicketIDs {
        for _, pl := range p.Teams[...] /* surface to your players */ {
            if accepted { mm.Accept(id, pl.ID) } else { mm.Reject(id, pl.ID) }
        }
    }
}
matches, _ = mm.Tick()             // fully-accepted proposals are now returned

When a proposed match fails acceptance — a player Rejects, or acceptanceTimeoutSeconds elapses before everyone responds — flexi follows AWS FlexMatch by splitting the proposal's tickets:

  • Tickets on which every player had already accepted return to the queue in SEARCHING and are re-considered by the next Tick. Matchmaker.StatusReason(id) reports StatusReasonAcceptanceFailed for them, so a caller polling Status can tell a re-entering ticket apart from a freshly enqueued one (and emit the corresponding MatchmakingSearching event).
  • The ticket(s) that caused the failure — the rejecting player's ticket, or any ticket whose players never responded — move to CANCELLED, whether the failure was a reject or an acceptance timeout. (FlexMatch reserves TIMED_OUT for the request-level deadline below, not for acceptance failures.) Resubmit with a fresh ticket ID for another attempt.
mm.Accept("a", "alice")
mm.Reject("b", "bob")              // proposal fails acceptance
mm.Status("a")                      // SEARCHING  (re-queued)
mm.StatusReason("a")                // StatusReasonAcceptanceFailed, true
mm.Status("b")                      // CANCELLED  (terminal)
mm.Tick()                           // re-matches "a" against the pool

A user-initiated Cancel on any participating ticket is different: it always dissolves the whole proposal, terminating every ticket as CANCELLED.

Request timeout

Set requestTimeoutSeconds on the rule set to bound how long a ticket may stay in matchmaking overall:

{
  ...
  "requestTimeoutSeconds": 60
}

Any QUEUED or re-queued (SEARCHING) ticket that has been waiting longer than this — measured from its original Enqueue, so the clock keeps running across an acceptance-failure re-queue — moves to TIMED_OUT on the next Tick. The deadline applies whether or not acceptanceRequired is set; tickets currently held in a proposal (REQUIRES_ACCEPTANCE) are governed by acceptanceTimeoutSeconds instead. Zero (the default) disables it.

Rule evaluation metrics

FlexMatch's match events (PotentialMatchCreated, MatchmakingTimedOut, MatchmakingCancelled) include ruleEvaluationMetrics — per-rule {ruleName, passedCount, failedCount} tallies. flexi reproduces these:

  • Every Match and Proposal carries RuleEvaluationMetrics, the pass/fail counts accumulated by the search that formed that (candidate) match. This maps to PotentialMatchCreated.
  • Matchmaker.RuleMetrics(id) returns the cumulative per-rule tallies for a ticket across every Tick it took part in, retained through terminal states. Use it for the MatchmakingTimedOut / MatchmakingCancelled events of tickets that never made it into a match.
matches, _ := mm.Tick()
for _, rm := range matches[0].RuleEvaluationMetrics {
    fmt.Printf("%s: passed=%d failed=%d\n", rm.RuleName, rm.PassedCount, rm.FailedCount)
}

// For a timed-out / cancelled ticket:
if metrics, ok := mm.RuleMetrics("ticket-1"); ok {
    // metrics[i].RuleName / PassedCount / FailedCount
}

Rule names match those declared in the rule set's rules block; a compound rule is reported once (its child evaluations are not listed separately). Each rule-set evaluation counts every rule (no short-circuit) so failedCount is complete. Tickets never involved in an evaluation report no metrics (the slice is nil / RuleMetrics returns false), keeping the addition backward compatible.

Backfill

Backfill is a request to fill the empty seats of a match that is already under way, rather than to form a new one. Matchmaker.EnqueueBackfill is the standalone counterpart of GameLift's StartMatchBackfill.

The request carries everyone currently in the game session, each tagged with the team they occupy. flexi seats those players first and matches new tickets around them, so the rules are evaluated over the existing roster and the candidates together and the session keeps satisfying the rule set it was formed under.

err := mm.EnqueueBackfill(flexi.Ticket{
    ID:            "backfill-1",
    GameSessionID: "gs-1", // optional; see below
    Players: []flexi.Player{
        {ID: "alice", Team: "red",  Attributes: flexi.Attributes{"skill": flexi.Number(1500)}},
        {ID: "bob",   Team: "red",  Attributes: flexi.Attributes{"skill": flexi.Number(1490)}},
        {ID: "carol", Team: "blue", Attributes: flexi.Attributes{"skill": flexi.Number(1510)}},
    },
})

The resulting Match describes every seat in the session — the players already there alongside the ones joining — and lists the backfill ticket in its TicketIDs.

Writing the request

  • Player.Team is required on every player, and rejected on the players of a regular Enqueue, mirroring AWS's "do not specify team if you are not using backfill". It names a key of Match.Teams, so a team declared with quantity > 1 must be given as the expanded name the player was reported under (red_2); the base name alone does not say which instance they are on.
  • Player.Latencies should carry only the region the session is running in, as AWS's LatencyInMs does.
  • At most 199 players may be listed, matching AWS's limit.
  • GameSessionID is optional and opts into FlexMatch's rule that a session has one outstanding backfill request: a new request supersedes a waiting one for the same session, which becomes CANCELLED. If the previous request has already been matched — awaiting acceptance, or being placed — the new one is refused with ErrBackfillInProgress. Leaving the field empty skips this bookkeeping.
  • A roster that breaks any of the above is rejected with an error wrapping ErrInvalidTicket; see Errors.

Matchmaking behaviour

A backfill request is an ordinary ticket in every other respect: it is QUEUED, tracked with Matchmaker.Status, counted by Matchmaker.Pending, subject to requestTimeoutSeconds, loosened by expansions as it waits, and withdrawn with Matchmaker.Cancel. Under acceptanceRequired it is proposed for acceptance like any other ticket, and the players already in the session must accept too — call Accept for them from the game server, as you would against AWS.

Two constraints follow AWS: at most one backfill request takes part in any match, and a match only forms if at least one new ticket joins.

algorithm.backfillPriority decides when matchmaking reaches for a backfill request:

Value Behaviour
high Try every backfill request, oldest first, before forming a new match.
low Reach for a backfill request only once no new match can be formed.
normal (default) No special standing: a backfill request takes part when it is the oldest ticket queued.

As in FlexMatch, the property applies to the exhaustiveSearch strategy only. A rule set may declare it alongside balanced — it is not rejected — but matchmaking then behaves as though it were unset.

Automatic backfill (BackfillMode: "AUTOMATIC") is out of scope. AWS does not offer it in standalone mode, and flexi knows nothing about game sessions beyond the optional identifier above.

Errors

Every error the package returns is classifiable with errors.Is, so a caller fronting an API — a GameLift-compatible service, say — can map a failure onto its own status codes without matching on message text.

Sentinel Returned when
ErrInvalidRuleSet New was given rule set JSON that is malformed or fails semantic validation.
ErrInvalidTicket The submitted ticket is not well formed: no ID, no players, an attribute whose kind disagrees with the rule set, a Team that is missing, unknown, or ambiguous, a team or roster over its limit. Wrapped by every input-validation failure of Enqueue and EnqueueBackfill, so one check classifies them all as the caller's own mistake (400, not 500).
ErrDuplicateTicket The ticket ID is already queued, in a proposal, or in a retained terminal state.
ErrUnknownTicket No ticket with that ID is tracked (or it has been evicted).
ErrBackfillInProgress The game session's outstanding backfill request has already been matched and cannot be superseded.
ErrTicketNotTerminal Evict was called on a ticket still in matchmaking.
ErrUnknownProposal / ErrUnknownPlayer Accept / Reject named a ticket that is not in a pending proposal, or a player who is not on the ticket.
ErrTicketNotPlacing MarkCompleted was called on a ticket in any status other than PLACING.

ErrInvalidTicket covers the ticket's contents; ErrDuplicateTicket and ErrBackfillInProgress describe the matchmaker's state and deliberately do not wrap it, since a caller answers them differently.

API at a glance

Full reference is on pkg.go.dev. The most-used surface:

Symbol Description
flexi.New(rulesetJSON, opts...) Parse a rule set JSON document and return a Matchmaker.
flexi.WithClock(c) Option that overrides the time source.
Matchmaker.Enqueue(t) Add a ticket to the queue.
Matchmaker.EnqueueBackfill(t) Add a request to fill the empty seats of a match in progress.
Matchmaker.Cancel(id) Remove a queued ticket, or dissolve a proposal it is part of.
Matchmaker.Evict(id) Forget a terminal ticket's retained status and metrics.
Matchmaker.Tick() Expire timed-out proposals, resolve accepted ones, and form new matches.
Matchmaker.Pending() Count of tickets currently in QUEUED.
Matchmaker.Status(id) Current TicketStatus for a ticket.
Matchmaker.StatusReason(id) Supplementary StatusReason (e.g. acceptance-failure re-queue), when one applies.
Matchmaker.RuleMetrics(id) Cumulative per-rule pass/fail tallies for a ticket (ruleEvaluationMetrics).
Matchmaker.PendingAcceptances() Snapshot of proposals in REQUIRES_ACCEPTANCE.
Matchmaker.Accept(id, playerID) / Reject(id, playerID) Record a player's decision on a proposed match.
Matchmaker.MarkCompleted(id) Promote a PLACING ticket to COMPLETED.
flexi.Number / String / StringList / StringNumberMap Constructors for the four Attribute variants.
flexi.NewFakeClock(t) Test clock you can Advance or Set.

Testing

go test ./... -race

Each layer (rule set parser, expression evaluator, rule evaluators, expansions, algorithm, queue) has its own unit tests. End-to-end scenarios driven through the public API live in examples_test.go. Tests use stretchr/testify.

License

See LICENSE for license terms.

About

A matchmaking library that is compatible with the FlexMatch ruleset.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages