# Matching rules

Matchmaking rule reference: player counts, skill windows, roles, shared preferences, and time-based relaxation, with examples.

Source: https://birdhouse.tech/docs/constraints/

Constraints describe what a ticket considers a valid match. The service evaluates every ticket's rules against the complete candidate group. A match is valid only when all of those individual rule trees pass.

## The rule model

Rules are JSON objects distinguished by `type`. Combine them using `and`, `or`, and `not`. Field rules read **ticket metadata** from `meta`, not individual player metadata.

Metadata alone does not separate queues. If `meta.mode` must match, add a `fieldEqualsDeep` rule for `mode`. The top-level [environment](https://birdhouse.tech/docs/environments/) is enforced independently and cannot be relaxed by rules.

The matcher combines at least two tickets. `playerCount` counts all players in those tickets; other count-based field rules generally count **tickets**.

## Group size and composition

### playerCount

Require a total number of players across the complete group, inclusive of both bounds.

```json
{ "type": "playerCount", "min": 4, "max": 4 }
```

Both values are nonnegative integers, and `max` must be at least `min`. Keep a player-count rule outside any branch that you intend to relax with a timeout.

### fieldEqualsDeep

Require equal values for a ticket metadata field. Values use deep equality.

```json
{ "type": "fieldEqualsDeep", "field": "mode" }
```

Without `min`, every ticket must have the same value. With `min`, at least that many tickets, including the current ticket, must share its value. Missing values compare equal to one another, so validate metadata in your game when the field is required.

### fieldExclusive

Require each other ticket's field value to differ from the current ticket's value.

```json
{ "type": "fieldExclusive", "field": "role" }
```

Only the ticket carrying the restriction needs this rule. For example, a ticket with `role = "hunter"` can prevent a second hunter from joining. If the current ticket has no value for the field, the rule passes.

### fieldPartition

Require an exact number of tickets for each declared string value.

```json
{
  "type": "fieldPartition",
  "field": "role",
  "partitions": { "tank": 1, "healer": 1, "damage": 2 }
}
```

Every ticket must have a listed value. Missing or unknown values fail. Partition counts are positive integers and count tickets, not players. Use this example with solo tickets if each role slot should represent one player, and pair it with `playerCount`.

## Proximity and shared preferences

### fieldWindow

Accept numeric metadata values within an expanding distance from the current ticket's value.

```json
{
  "type": "fieldWindow",
  "field": "skill",
  "spreadRate": 2,
  "min": 50,
  "max": 300
}
```

The allowed difference is `min(max, (min ?? 0) + ageSeconds × spreadRate)`. `spreadRate` is a nonnegative number in field units per second. `min` defaults to zero; omitted `max` means no cap.

The example starts at ±50 skill points and reaches ±300 after 125 seconds. Each ticket evaluates its own age and window, so every member must accept the other members. Missing or non-numeric values fail. Numeric strings are accepted, and an empty or whitespace-only string is read as `0`, so send the value as a number.

### fieldDistance

Use the same expanding-window model for geographic coordinates, measured in kilometers using great-circle distance.

```json
{
  "type": "fieldDistance",
  "field": "location",
  "spreadRate": 1.5,
  "min": 50,
  "max": 500
}
```

The metadata value must be a two-element `[latitude, longitude]` array in decimal degrees. Missing or non-numeric coordinates fail. The example expands from 50 km to 500 km over five minutes; this measures geographic distance, not network latency.

### fieldIncludes

Require array-valued fields to share at least one deeply equal element.

```json
{ "type": "fieldIncludes", "field": "languages" }
```

For example, `{"languages": ["en", "es"]}` intersects `{"languages": ["es", "fr"]}`. Without `min`, the current ticket must overlap every other ticket. With `min`, at least that many tickets, including itself when it intersects, must overlap its array.

Pairwise overlap does not guarantee one common element across the entire group. Missing or non-array fields do not count as an overlap.

## Logic and time

### and, or, and not

| Rule | Shape | Passes when |
| --- | --- | --- |
| `and` | `{ "type": "and", "constraints": [...] }` | Every child passes. An empty list passes. |
| `or` | `{ "type": "or", "constraints": [...] }` | At least one child passes. An empty list fails. |
| `not` | `{ "type": "not", "constraint": {...} }` | Its single child fails. |

### timeout

A timeout becomes true once the ticket's age reaches `after` seconds. It does not cancel a search, expire a ticket, or force a match.

```json
{ "type": "timeout", "after": 90 }
```

Combine it with `or` to relax one part of a rule. Other tickets' constraints still need to pass. Environment isolation always remains in force.

## A ranked queue

This rule requires four players and the same mode. The skill window starts at 50 points, expands by two points per second, and becomes optional after 90 seconds.

```luau
local constraints = {
    type = "and",
    constraints = {
        { type = "playerCount", min = 4, max = 4 },
        { type = "fieldEqualsDeep", field = "mode" },
        {
            type = "or",
            constraints = {
                {
                    type = "fieldWindow",
                    field = "skill",
                    spreadRate = 2,
                    min = 50,
                    max = 300,
                },
                { type = "timeout", after = 90 },
            },
        },
    },
}
```

Supply corresponding ticket metadata such as `meta = { mode = "ranked", skill = 1200 }`. Keep the player-count and mode rules outside the `or` so the timeout relaxes only skill proximity.

## Design for your population

Rules can be valid yet impossible to satisfy with the available parties. An exact four-player queue with many three-player parties needs enough solo parties to complete them. A capped skill window cannot guarantee a match if no compatible players arrive.

Watch wait time, incoming ticket rate, and match rate together. Use gradual relaxation for preferences, retain hard requirements explicitly, and test both sparse queues and mixed party sizes before release.
