---
title: "Challenges"
description: "The product unit: metric, direction, threshold, fee and prize; the calibrating / live / suspended status machine and which statuses accept attempts; how a Challenge is created and where it surfaces to players."
url: https://www.withchallenge.com/docs/challenges
canonical_markdown: https://www.withchallenge.com/docs/challenges.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# 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 `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](https://www.withchallenge.com/docs/calibration.md) 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](https://www.withchallenge.com/docs/challenge-ladder.md).

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](https://www.withchallenge.com/docs/calibration.md) 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

```html
<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](https://www.withchallenge.com/docs/challenge-js.md).

### The API

**Runs on your server.**

```bash
# 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"
```

```json
{
  "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.

| 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](https://www.withchallenge.com/docs/calibration.md): how the bar is placed and kept honest
- [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md): what claiming a Challenge unlocks
- [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): tiers, ceilings and what a key may do
- [API](https://www.withchallenge.com/docs/api.md): the `/v1` reference

---

Source: https://www.withchallenge.com/docs/challenges.md
Docs index for AI agents: https://www.withchallenge.com/llms.txt
