Ad Matching Pipeline

Every time a game requests an ad via POST /api/ads/match, the matching engine runs a deterministic 6-step pipeline to find the best campaign. Each step is an elimination filter -- campaigns that fail any step are discarded. Survivors are scored and the highest-scoring campaign wins.

Pipeline Flow


  POST /api/ads/match
         |
         v
  +------------------+
  | 1. Placement     |----> REJECT: placements not enabled for this game
  |    Check         |
  +------------------+
         |
         v
  +------------------+
  | 2. Active        |----> REJECT: expired, paused, or over-budget campaigns
  |    Campaigns     |
  +------------------+
         |
         v
  +------------------+
  | 3. Geo           |----> REJECT: player location not in target regions
  |    Filter        |
  +------------------+
         |
         v
  +------------------+
  | 4. Blocklist     |----> REJECT: scene context matches blocked keywords/themes
  |    Filter        |
  +------------------+
         |
         v
  +------------------+
  | 5. Creator       |----> REJECT: game developer blocked this advertiser
  |    Filter        |
  +------------------+
         |
         v
  +------------------+
  | 6. Fatigue       |----> REJECT: session/creative frequency caps exceeded
  |    Check         |
  +------------------+
         |
         v
  +------------------+
  |   SCORING        |----> Rank surviving campaigns by composite score
  +------------------+
         |
         v
  Winner returned (or 204 No Content if no survivors)
  

Step 1: Placement Check

The game must have ad placements explicitly enabled. This is a binary gate -- if the game has not called POST /api/ads/placements/{game_id} to register, or if the placement record has enabled = false, the pipeline short-circuits immediately with 204 No Content.

What is checked:

  • The ad_placements table has a row for game_id
  • That row has enabled = true
  • The placement has at least one placement_type configured (e.g., narrative_hook, product_mention)

This step exists so game developers have full control. Ads never appear in a game unless the developer has opted in.

Step 2: Active Campaigns

All campaigns in the system are filtered to only those currently eligible to serve impressions. A campaign must satisfy all four conditions:

Condition Column Rule
Status status Must be 'active' (not draft, paused, or completed)
Start date start_date Must be <= today (campaign has started)
End date end_date Must be >= today (campaign has not expired)
Budget spent_cents, budget_cents spent_cents < budget_cents (budget not exhausted)

Campaigns that fail any of these conditions are discarded. This step typically eliminates the majority of campaigns in the system, keeping only the live, funded ones.

Step 3: Geo Filter

Each campaign can define geographic targeting rules in the campaign_geo_targets table. The player's location (provided in player_geo in the match request) is checked against these rules.

Targeting Modes

Scenario Behavior
No geo targets defined Campaign is global -- matches all players regardless of location
include targets exist Player must match at least one include target
exclude targets exist Player must not match any exclude target
Both include and exclude Must match an include AND not match any exclude

Granularity Levels

Geo targets support four levels of geographic precision:

  • Country -- country_code (ISO 3166-1 alpha-2, e.g., "US", "GB", "DE")
  • State / Region -- state (e.g., "California", "Bavaria")
  • City -- city (e.g., "San Francisco", "Berlin")
  • Radius -- lat, lon, radius_km -- uses the Haversine formula to compute great-circle distance between the player's coordinates and the target center

The Haversine check uses Earth's mean radius (6,371 km) and computes:

d = 2R * arcsin(sqrt(
    sin^2((lat2 - lat1) / 2) +
    cos(lat1) * cos(lat2) * sin^2((lon2 - lon1) / 2)
))

If d <= radius_km, the player is within the target radius.

Step 4: Blocklist Filter

Advertisers can define blocklists to prevent their ads from appearing in unwanted contexts. The campaign_blocklist table supports five blocklist types:

Type What it blocks Matched against
keyword Specific words or phrases Substring search in game_context.narrative
theme Game themes by ID Exact match with game_context.theme_id
category Game categories Exact match with game_context.category
action Narrative actions Exact match with current narrative function
game_id Specific games Exact match with game_id

If any blocklist entry matches the current scene context, the campaign is eliminated. For example, a family brand might blocklist the keyword "assassination" and the theme "deep_state" to avoid appearing alongside dark political content.

Step 5: Creator Filter

Game developers (creators) can block specific advertisers or entire advertiser categories from appearing in their games. This gives creators editorial control over which brands are associated with their content.

Two checks are performed:

  • blocked_advertisers -- A list of advertiser IDs the creator has blocked. If the campaign's advertiser is in this list, it is rejected.
  • blocked_categories -- A list of advertiser categories (e.g., "gambling", "alcohol", "politics"). If the campaign's category is in this list, it is rejected.

These preferences are set via the creator's dashboard or the PUT /api/ads/placements/{game_id} endpoint.

Step 6: Fatigue Check

The fatigue check prevents ad overexposure. Two limits are enforced:

Limit Scope Source
max_uses_per_game Per creative, per game campaigns table
max_ads_per_session Total ads across all campaigns, per session ad_placements table

max_uses_per_game prevents a single creative from being shown repeatedly in the same game. The system counts how many impressions this creative has recorded for this game_id. If the count equals or exceeds the limit, this campaign's creative is excluded.

max_ads_per_session is set by the game developer and caps the total number of ad impressions in a single play session (identified by session_id). If the session has already reached its cap, all campaigns are excluded and the pipeline returns 204 No Content.

Scoring Function

Campaigns that survive all six filters are scored using a composite formula. The highest-scoring campaign wins the impression.

Formula

Score = base_cpm × category_affinity × priority_boost × affinity_match

Components

Factor Source Calculation
base_cpm campaign.cpm_bid_cents The campaign's CPM bid in cents. Higher bids increase priority. A $2.00 CPM = 200.
category_affinity campaign_affinities + game_context.category 1.5x if the campaign has a category affinity targeting the current game's category; otherwise 1.0x
priority_boost campaign.priority 1.0 + (priority * 0.1). Priority ranges from 0-10, giving a boost of 1.0x to 2.0x.
affinity_match campaign_affinities + game_context 1.0 + (matched_weight_sum / total_affinities). Sum of weights for affinities that match the scene, divided by the total number of affinities on the campaign.

Scoring Example

Consider a campaign with:

  • CPM bid: $2.00 (200 cents)
  • Priority: 3
  • Category affinity for "simulation" (weight 1.5) -- matches current game
  • Lighting affinity for "warm" (weight 2.0) -- matches scene
  • Mood affinity for "celebration" (weight 1.0) -- does not match scene
  • Total affinities: 3
base_cpm         = 200
category_affinity = 1.5   (category affinity matches game category)
priority_boost   = 1.0 + (3 × 0.1) = 1.3
affinity_match   = 1.0 + ((1.5 + 2.0) / 3) = 1.0 + 1.167 = 2.167

Score = 200 × 1.5 × 1.3 × 2.167 = 845.1

A competing campaign with a $3.00 CPM but no matching affinities and priority 0 would score:

Score = 300 × 1.0 × 1.0 × 1.0 = 300

The first campaign wins despite the lower CPM bid, because its affinities and priority create a higher composite score. This incentivizes advertisers to target precisely rather than simply outbid competitors.

Match Response

When a campaign wins, the system returns the creative details along with pipeline bias instructions that the game engine can apply to its scene renderer:

{
  "campaign_id": "camp_abc123",
  "creative_id": "crt_def456",
  "narrative_hook": "A familiar logo on the briefing folder catches your eye...",
  "creative_type": "narrative_hook",
  "brand_name": "Acme Corp",
  "biased_pipeline": {
    "lighting": "warm golden hour",
    "effects": "cinematic widescreen, film grain"
  },
  "cpm_bid_cents": 200
}

If no campaigns survive the pipeline, the endpoint returns 204 No Content. The game should proceed with its default scene -- no ad is shown, and no impression is recorded.

Related