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.
- 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]).ruleLanguageVersionis 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, withdefaultvalues applied to players that omit an attribute. A value whose kind disagrees with the declared type is rejected atEnqueue; 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(andunion/intersectionfor 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:
exhaustiveSearchandbalancedstrategies,batchingPreference(random/sorted/largestPopulation/fastestRegion),sortByAttributes,backfillPriority,expansionAgeSelection. - Backfill:
EnqueueBackfillfills the empty seats of a match already under way — the standalone counterpart ofStartMatchBackfill— 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 (
QUEUED→REQUIRES_ACCEPTANCE→PLACING→COMPLETED, plusSEARCHING/CANCELLED/TIMED_OUT) driven byacceptanceRequired/acceptanceTimeoutSeconds/requestTimeoutSecondson the rule set. On a failed acceptance (reject or acceptance timeout) the tickets that did accept return toSEARCHINGfor re-matching while the rest areCANCELLED;TIMED_OUTis reserved for the request-levelrequestTimeoutSeconds, 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/Rejectwhile another goroutine drivesTick.
Automatic backfill (
BackfillMode: "AUTOMATIC") is out of scope — AWS itself does not offer it in standalone mode. Manual backfill is supported; see Backfill.
go get github.com/moepig/flexiRequires Go 1.26 or later.
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"])
}
}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.
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{
"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}
]
}
]
}A few rule types have subtle AWS semantics that flexi follows deliberately:
collectionoperationscontains— counts how many times the reference value appears across the (flattened) measurement and bounds that count withminCount/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 noreferenceValue(e.g. "all players share at least one game mode",minCount: 1).reference_intersection_count— requires each player's collection to intersect thereferenceValuecollection withinminCount/maxCount. ThereferenceValuemay be a literal array or a property expression such asset_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 stringbatchDistancewith nomaxDistancetherefore requires every player to share the same value (the AWS "SameGameMode" form). (minDistanceis adistance-rule property and is not accepted onbatchDistance.)maxDistance/minDistanceaccept either a JSON number (500) or a string-encoded number ("500"); the AWS docs use both forms.
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.
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 againA 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 returnedWhen 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
SEARCHINGand are re-considered by the nextTick.Matchmaker.StatusReason(id)reportsStatusReasonAcceptanceFailedfor them, so a caller pollingStatuscan tell a re-entering ticket apart from a freshly enqueued one (and emit the correspondingMatchmakingSearchingevent). - 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 reservesTIMED_OUTfor 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 poolA user-initiated Cancel on any participating ticket is different: it always
dissolves the whole proposal, terminating every ticket as CANCELLED.
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.
FlexMatch's match events (PotentialMatchCreated, MatchmakingTimedOut,
MatchmakingCancelled) include ruleEvaluationMetrics — per-rule
{ruleName, passedCount, failedCount} tallies. flexi reproduces these:
- Every
MatchandProposalcarriesRuleEvaluationMetrics, the pass/fail counts accumulated by the search that formed that (candidate) match. This maps toPotentialMatchCreated. Matchmaker.RuleMetrics(id)returns the cumulative per-rule tallies for a ticket across everyTickit took part in, retained through terminal states. Use it for theMatchmakingTimedOut/MatchmakingCancelledevents 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 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.
Player.Teamis required on every player, and rejected on the players of a regularEnqueue, mirroring AWS's "do not specify team if you are not using backfill". It names a key ofMatch.Teams, so a team declared withquantity > 1must 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.Latenciesshould carry only the region the session is running in, as AWS'sLatencyInMsdoes.- At most 199 players may be listed, matching AWS's limit.
GameSessionIDis 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 becomesCANCELLED. If the previous request has already been matched — awaiting acceptance, or being placed — the new one is refused withErrBackfillInProgress. Leaving the field empty skips this bookkeeping.- A roster that breaks any of the above is rejected with an error wrapping
ErrInvalidTicket; see Errors.
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.
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.
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. |
go test ./... -raceEach 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.
See LICENSE for license terms.