Challenges
A Challenge is one standard a player can pay to attempt: a metric, a bar, a fee, and a fixed prize Challenge pays to anyone who beats it.
What a Challenge is made of
| Field | Meaning |
|---|---|
name | What the player reads on the card |
metric_name | What is measured, in your game's vocabulary: moves, lap time, combo length |
metric_unit | What it is measured in: moves, seconds, points |
threshold_direction | higher_is_better or lower_is_better. Which end of the scale wins. |
threshold | The bar. Measured from real play, never chosen by hand. null while calibrating. |
fee | What one paid attempt costs, as a decimal string |
prize | What beating the bar pays, as a decimal string. The prize on offer, not the prize configured. |
status | calibrating, live, suspended or closed |
calibration_source | How the bar was placed: observed, listened, partner_seeded or rung_derived |
rung | How far up a ladder this sits. 1 for an ordinary Challenge. |
parent_challenge_id | The Challenge a player must have claimed to attempt this one. null on rung 1. |
The metric is what identifies a distribution, not the unit. A racing game's lap time and a puzzle game's solve time are both measured in seconds and are not the same thing, so two Challenges that share a metric_name share a sample and two that share only a unit do not.
Metric names are normalised to trim, lowercase and single spaces before anything is counted, so Combo Length, combo length and combo length are one metric. They are capped at 40 characters. The string your game passes to Challenge.record() and the string on the Challenge have to be the same string, or the Challenge waits forever for a sample that is accumulating somewhere else.
What you choose and what Challenge chooses
You choose the question. Challenge chooses what it costs and what it pays.
| You | Challenge |
|---|---|
| Name, description, metric name, unit, direction | The threshold |
| Which Challenges exist at all | The fee and the prize |
| When to create them | The target pass rate, and when the bar is trustworthy enough to charge |
This split is not a missing feature. The prize is fixed and comes off Challenge's balance sheet, so the bar sets the pass rate, which sets the expected cost of every attempt. A partner who set their own bar would be setting Challenge's cost per attempt, which is why there is no field for it anywhere in the API.
The status machine
calibrating ──────► live ──────► suspended
(measuring) (charging) (pulled by the drift monitor)
│
└──────► closed (retired)| Status | For the player | For your integration |
|---|---|---|
calibrating | Free play. Nothing is charged and nothing can be won. | Attemptable. fee and prize are 0.00, threshold is null. |
live | Charging and paying. | Attemptable. The bar and the prize are frozen onto each attempt at its start. |
suspended | Gone from the island. | Not attemptable. POST /v1/attempts answers challenge_not_available. |
closed | Retired. | Not attemptable. Same refusal. |
Two of these are enforced by the database rather than by application code, which is why they hold under every path that can write a Challenge:
- A
calibratingChallenge hasfee = 0. A standard that has not been measured cannot charge for itself. - A
liveChallenge has athreshold. Going live without a bar would mean nobody can be judged, or everybody wins.
Both live and calibrating are attemptable, and that is deliberate. Calibration needs attempts to gather scores, so refusing them until the Challenge was live would deadlock the model: no scores, no bar, no live. A calibrating attempt takes no fee, can win nothing, and is exempt from the paid-attempt guards below for the same reason.
Suspension is a circuit breaker, not a deletion. See Calibration for what trips it. Attempts already open when it trips keep the threshold captured at their creation, so nobody's result changes retroactively.
One prize per player
A Challenge pays each player once, for life. Once a player has been paid, every further paid attempt on that Challenge answers challenge_already_claimed (409), and the refusal is permanent for that pair.
A claim is an award, not a met threshold. Awards exist only for paid attempts that cleared the bar for a prize above zero, so a free calibration round that happened to beat a bar set later never locks anyone out of the Challenge they helped measure.
What claiming does instead is unlock the next rung. See The Challenge ladder.
There is also a rolling daily cap: 10 paid attempts per player per Challenge per day, answering attempt_cap_reached (409). It is high enough that nobody practising a Challenge meets it, and low enough that one account cannot outrun the drift monitor's measuring window. Free calibration attempts do not count against it.
Creating a Challenge
In the Developer Portal, on the program. There is no POST /v1/challenges, and there will not be one: creating a Challenge is a pricing decision Challenge participates in, not a request a partner can make unattended.
You supply the name, an optional description, the metric name, the unit and the direction. Names and metric names are screened at the moment you type them, so a Challenge that Challenge cannot host is refused before you build against it rather than at review.
Every new Challenge is written the same way, wherever it was asked for: calibrating, fee 0, prize 0, no threshold, target pass rate 10%.
Then one of two things happens in that same request:
- If your program has already recorded enough rounds of that metric, the bar is computed from them and the Challenge is live before the response returns.
- If it has not, the Challenge stays
calibratingand collects free attempts until it has enough.
That is why recording ordinary rounds is the first thing to ship and not the last. Calibration covers both paths, and the third.
Where Challenges surface
The island
Challenge.js renders the program's Challenges itself: the card, the fee, the prize, the bar, the arming flow, the account, the age check, the deposit and the result screen. A game that calls Challenge.init({ program }) is already listing Challenges and needs no menu of its own.
The island also lists locked rungs, marked as locked and unpriced, because a locked rung is the thing a ladder is selling. That is the one place they appear.
Your own layout
<challenge-list></challenge-list>
<challenge-card challenge-id="<<YOUR_CHALLENGE_ID>>"></challenge-card>Tapping a card runs the same arming flow, so there is still exactly one place a player agrees to a price. See Challenge.js.
The API
Runs on your server.
# The key lives on your server. It never reaches the browser.
curl https://api.withchallenge.com/v1/challenges?player_ref=player_42 \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"data": [
{
"id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"name": "Fewest Moves",
"description": "Solve the board in as few moves as you can.",
"fee": "1.00",
"prize": "4.50",
"currency": "USD",
"status": "live",
"prize_structure": "threshold",
"threshold": 27,
"min_calibration_n": 1000,
"calibration_source": "observed",
"metric_name": "moves",
"metric_unit": "moves",
"rung": 1,
"parent_challenge_id": null,
"ramp": {
"active": true,
"fullPrize": "9.00",
"attemptsSeen": 132,
"attemptsRequired": 200
}
}
]
}player_ref is required, and it is what makes the list safe to render directly. Everything returned is something POST /v1/attempts will accept for that player:
- Only
liveChallenges, by default. Add?status=allfor the partner-facing view, which returns your Challenges in every status and reports calibration progress on the ones still measuring. A player-facing menu should never send it. - Challenges this player has already claimed are absent, because a claimed Challenge cannot be attempted again.
- Locked rungs are absent, because a rung this player has not unlocked cannot be attempted yet.
An unknown player_ref is not an error here. Someone who has never played has claimed nothing, which locks every rung and claims none, and both of those are the true answer.
Money is a decimal string everywhere in /v1. Binary floats cannot represent cents exactly, and a partner parsing 25.00 as 24.999999 would be a bug Challenge owned.
The prize on offer, not the prize configured
prize is what this player would be offered right now. During a Challenge's go-live ramp that is half the configured prize, and the ramp object says so: fullPrize is what it becomes, attemptsSeen against attemptsRequired is how far along it is.
Always render prize. It is the number the player is about to pay against, and it is the number POST /v1/attempts will freeze onto the attempt.
On a calibrating Challenge the response carries a calibration object instead, with collected against required and playersCollected against playersRequired.
Attempting one
Both doors freeze the bar and the prize at creation, and neither of them is a place your code decides what a player agreed to pay.
| Door | Who authenticates | Use it when |
|---|---|---|
await Challenge.startAttempt() | The player's own session | The player is in your game with the island in front of them. This is the usual case. |
POST /v1/attempts with a player_ref | Your API key | Your server drives the whole flow and there is no browser client |
Failures worth branching on, none of which charge anything:
| Code | Meaning |
|---|---|
challenge_not_available | Suspended, closed, or a prize over your tier ceiling |
challenge_already_claimed | This player has been paid this Challenge. Permanent for the pair. |
rung_locked | A rung whose parent this player has not claimed |
attempt_cap_reached | Ten paid attempts on this Challenge today. Clears as the day rolls forward. |
insufficient_funds | The island offers a deposit. Nothing was charged. |
Related
- Calibration: how the bar is placed and kept honest
- The Challenge ladder: what claiming a Challenge unlocks
- Programs and keys: tiers, ceilings and what a key may do
- API: the
/v1reference
Source: https://www.withchallenge.com/docs/challenges.md Docs index for AI agents: https://www.withchallenge.com/llms.txt
Ready to integrate?
Put a Bounty in your game in minutes.
Challenge for Players
Create an account, deposit funds, and compete for real money in games you already play.
Challenge for Developers
Add Challenge to your game and start earning from every match. Integration takes minutes.