Challenge
Docs>Concepts>Challenges

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

FieldMeaning
nameWhat the player reads on the card
metric_nameWhat is measured, in your game's vocabulary: moves, lap time, combo length
metric_unitWhat it is measured in: moves, seconds, points
threshold_directionhigher_is_better or lower_is_better. Which end of the scale wins.
thresholdThe bar. Measured from real play, never chosen by hand. null while calibrating.
feeWhat one paid attempt costs, as a decimal string
prizeWhat beating the bar pays, as a decimal string. The prize on offer, not the prize configured.
statuscalibrating, live, suspended or closed
calibration_sourceHow the bar was placed: observed, listened, partner_seeded or rung_derived
rungHow far up a ladder this sits. 1 for an ordinary Challenge.
parent_challenge_idThe 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.

YouChallenge
Name, description, metric name, unit, directionThe threshold
Which Challenges exist at allThe fee and the prize
When to create themThe 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)
StatusFor the playerFor your integration
calibratingFree play. Nothing is charged and nothing can be won.Attemptable. fee and prize are 0.00, threshold is null.
liveCharging and paying.Attemptable. The bar and the prize are frozen onto each attempt at its start.
suspendedGone from the island.Not attemptable. POST /v1/attempts answers challenge_not_available.
closedRetired.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 calibrating Challenge has fee = 0. A standard that has not been measured cannot charge for itself.
  • A live Challenge has a threshold. 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 calibrating and 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 live Challenges, by default. Add ?status=all for 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.

DoorWho authenticatesUse it when
await Challenge.startAttempt()The player's own sessionThe player is in your game with the island in front of them. This is the usual case.
POST /v1/attempts with a player_refYour API keyYour server drives the whole flow and there is no browser client

Failures worth branching on, none of which charge anything:

CodeMeaning
challenge_not_availableSuspended, closed, or a prize over your tier ceiling
challenge_already_claimedThis player has been paid this Challenge. Permanent for the pair.
rung_lockedA rung whose parent this player has not claimed
attempt_cap_reachedTen paid attempts on this Challenge today. Clears as the day rolls forward.
insufficient_fundsThe island offers a deposit. Nothing was charged.

Related


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.