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_placementstable has a row forgame_id - That row has
enabled = true - The placement has at least one
placement_typeconfigured (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
- Affinity Targeting -- deep dive into all 8 affinity types
- Analytics API -- track campaign performance
- Quickstart -- integrate in 5 minutes
- Pipeline Bias -- how affinities change visual output
- Placement Simulator -- interactive demo