Progression

Find out where players stop playing, and how long they lasted before they did.

What it is

A progression report is a funnel: an ordered list of milestones your game has (first launch, tutorial finished, level 1 cleared, first purchase) and, for each one, how many players got there. The gap between two steps is the number of people your game lost between them.

It is the fastest way to find the one screen, fight, or loading step that costs you most of your players, because it points at a specific place instead of a general feeling. Analytics tells you players are leaving; this tells you where.

It takes two things: naming your milestones, and calling one method when a player reaches one.

1

Declare your milestones

List them in the order players are meant to reach them. This is optional (the report can guess an order from how many players reached each step) but declaring it means the report measures the sequence you actually mean, and re-ordering the list re-orders the report on the next play.

<script src="https://indie.fun/js/indie.js"></script>
<script>
  const indie = new Indie({
    appId: 'your-app-id',
    progression: [
      'game_start',
      'tutorial_done',
      'level_1_clear',
      'level_2_clear',
      'first_purchase',
    ],
  });
</script>

Make the first step the moment your game becomes playable

A funnel starts at its first step, so anyone who never reaches that step is not in the report at all. Start it at a milestone deep in your game and every player you lost before it is invisible. On one game here, the funnel opened at tutorial_complete and could see 18 of its 25 players. The other 7 had played and left, and nothing in the report said so.

So call progress('game_start') the moment a player can actually play, not on page load, and not on a menu or a loading screen. That one step turns two questions you cannot otherwise ask into ordinary rows: how many people who opened your game never got as far as playing it, and how long it took the ones who did (the medianSecondsFromEntry under your first step is your time-to-playable).

Adding it re-bases everything below: your old first step stops being 100% and becomes whatever share of starters reach it. Nothing got worse. The denominator just stopped flattering you.

Naming steps

  • Letters, digits, spaces and _ - . : /, up to 64 characters. Commas are not allowed. They separate steps in the report query.
  • Name the event, not the screen: tutorial_done rather than tutorial. You want to know who finished it, not who saw it.
  • Keep names stable across releases. Renaming a step starts a new one, and the old funnel splits in two.
  • Don't put anything player-identifying in a step name. It is a label, not a payload.
2

Call progress() where the milestone happens

indie.progress('game_start');       // as soon as the game is playable
indie.progress('tutorial_done');    // when the tutorial is actually finished
indie.progress('level_1_clear');    // on the win screen, not on level load
indie.progress('first_purchase');

Call it at the moment the thing is true. A step reported when a level loads answers “who saw it”; the same step reported when it is cleared answers “who got through it”, and only the second one tells you where players give up.

What the SDK does with it

  • Only the first reach counts. Calling progress() again for the same player (a replay, a retry, a reload) never changes their conversion. It shows up separately as repeats per player, which is how you spot a level people grind.
  • Calls are batched and sent a moment later, so three milestones at the end of a level cost one request, and flushed again when the page closes, so the milestone someone reached just before quitting still lands. That one matters most: it is the last thing a churning player did.
  • Timing is recorded. Each first reach carries how far into the play session it happened, so the report can say when as well as where.
  • Consent is respected. Anonymous players are counted the same way they are for DAU. Nothing is recorded for a visitor who declined analytics, or who sends Do Not Track. See Analytics.

From your game server

For milestones only the server can vouch for (a purchase it settled, a boss fight it refereed) report them with the server SDK instead. Both write to the same funnel.

const { Indie } = require('indie-sdk/server');

const indie = new Indie({
  appId: 'your-app-id',
  appSecret: process.env.INDIE_APP_SECRET,
  progression: ['game_start', 'tutorial_done', 'boss_defeated'],
});

await indie.progress(player.id, 'boss_defeated');
await indie.progress(player.id, ['wave_10', 'first_purchase']);
3

Read the report

Check it worked

Open your game's Dashboard tab. The funnel sits under the player charts and follows the same range and population filters, so it describes the same slice of players.

Nothing there yet? Every step in the funnel is something we actually received. If a milestone is missing, the progress() call for it has not run. The setup checklist under Settings → Setup says how many milestones have reached us at all.

The same report is available as JSON. The endpoint below is the one the dashboard itself calls, so a script, a scheduled job, or a coding agent reads exactly what you see.

curl -H "X-App-Id: $INDIE_APP_ID" \
     -H "X-App-Secret: $INDIE_APP_SECRET" \
     "https://indie.fun/api/reports/progression?days=30"

Or from Node, with the server SDK:

const report = await indie.getProgressionReport({ days: 30 });

console.log(report.summary.headline);
// Players drop most between "tutorial_done" and "level_1_clear":
// 180 of 328 (55%) never get there. 37 of 412 finish the funnel.

GET /api/reports/progression

daysTrailing window in whole UTC days, ending today. 1–365, default 30.
stepsThe funnel to measure, in order: steps=game_start,tutorial_done,level_1_clear. Omit to use the order your game declared.
populationall (default) counts anonymous players too; loggedin counts only players who signed in.
The App Secret authenticates this endpoint, so it belongs on a server or in a local environment file, never in the page that runs your game. Inside the portal the same report is at /api/me/creations/[gameId]/progression, on your session cookie.

What comes back

{
  "generatedAt": "2026-07-27T12:00:00.000Z",
  "appId": "app_...",
  "population": "all",
  "range": { "days": 30, "start": "2026-06-28", "end": "2026-07-27" },
  "stepOrder": "declared",
  "funnel": [
    { "step": "game_start",    "position": 1, "players": 412, "conversionFromEntry": 100,
      "conversionFromPrevious": null, "droppedFromPrevious": null, "dropOffRate": null,
      "medianSecondsFromEntry": 0, "medianSecondsIntoSession": 4, "reachesPerPlayer": 1.1 },
    { "step": "tutorial_done", "position": 2, "players": 328, "conversionFromEntry": 80,
      "conversionFromPrevious": 80, "droppedFromPrevious": 84, "dropOffRate": 20,
      "medianSecondsFromEntry": 190, "medianSecondsIntoSession": 194, "reachesPerPlayer": 1 }
  ],
  "summary": {
    "entered": 412, "completed": 37, "completionRate": 9,
    "biggestDrop": { "from": "tutorial_done", "to": "level_1_clear", "fromPlayers": 328,
                     "lostPlayers": 180, "dropOffRate": 55, "medianSecondsFromEntry": 190 },
    "missingEntryStep": 0,
    "headline": "Players drop most between ..."
  },
  "unmappedSteps": [],
  "notes": ["..."],
  "definitions": { "funnel[].players": "..." }
}
players
Players in the cohort who reached this step at least once.
conversionFromPrevious
Those players as a percent of the step before. Null at the entry step, which has nothing to convert from.
droppedFromPrevious / dropOffRate
Players who reached the previous step and never this one, in absolute numbers and as a percent of that previous step.
medianSecondsFromEntry
Median wall-clock seconds from entering the funnel to first reaching this step. Spans sessions and days.
medianSecondsIntoSession
Median seconds of play into a single session at which this step is first reached, the "when in a session" answer. It runs on the same clock as session length, so time the tab spent hidden or idle is not in it. Null when no client reported it.
reachesPerPlayer
Mean reaches per player who got here. Above 1 means replays or retries.
stepOrder
"requested" (you passed steps=), "declared" (your SDK progression list), or "inferred", guessed from player counts because neither was given.
unmappedSteps
Steps your game records that this funnel leaves out. Ask for any of them by name to measure a sub-funnel.
Every report carries its own definitions and notes. That is deliberate: a report handed to a teammate, or pasted into an agent, has to explain itself, and the notes say which caveats apply to these numbers (an order that had to be guessed, a step nobody reached, a window with no players in it).

How the numbers are counted

A player enters the funnel at its first step
The window filters on the day they entered; every later step counts whenever they reached it, however long after. A run that spans a week is not cut in half by a 7-day view, and a player is never reported as churned for still playing.
Each player counts once per step, on their first reach
Repeats move reachesPerPlayer, never the conversion. Otherwise a level people replay would look like a wildly successful one.
Drop-off is measured against the step before it
Not against the entry. A step that loses half the players who reached the previous one shows dropOffRate: 50, whether that is half of ten thousand or half of ten.
A player is their account when signed in, and their device otherwise
With a device folded into the account it belongs to. It is the same identity your DAU and retention use, so the funnel and the charts above it describe the same people.
Players who never reached the first step are not drop-offs
They are counted separately as summary.missingEntryStep, a missing progress() call somewhere, not churn. If that number is large, your funnel’s first step is not the first thing your game reports.

Getting something useful out of it

  • Instrument the first minute densely. Most games lose most of their players before anything interesting happens, and a funnel that starts at “level 3” cannot see it. Steps for launch, first input, and the end of the tutorial are usually where the answer is.
  • Use the first step to measure loading. With game_start at the front, the drop before your next step is people who opened the game and left while it was still getting ready, and medianSecondsFromEntry on that step is how long they waited. A big drop with a long median is a loading problem; a big drop with a short one is a first screen that puts people off.
  • Read the biggest drop with its timing. medianSecondsFromEntry on the step before it tells you how long people had invested before they left: twenty seconds is a broken or confusing screen, twenty minutes is a difficulty wall.
  • Watch reachesPerPlayer. A step players reach five times each is one they are failing and repeating; a drop-off at the next step is a difficulty problem, not a content one.
  • Compare windows. Run the same funnel with days=7 before and after a release. A step whose conversion moved is a step your release touched.
  • Measure sub-funnels with steps=. The declared order is a default, not a limit. Ask for any sequence of steps you have recorded.

A worked example

A platformer instruments its opening, then asks what the first release week did.

// 1. Declare the funnel where the SDK is created.
const indie = new Indie({
  appId: 'your-app-id',
  progression: ['game_start', 'first_move', 'tutorial_done', 'level_1_clear', 'level_2_clear'],
});

// 2. Report each milestone where it happens.
indie.progress('game_start');                       // game loop running
onFirstInput(() => indie.progress('first_move'));   // player actually moved
onTutorialComplete(() => indie.progress('tutorial_done'));
onLevelClear((n) => indie.progress(`level_${n}_clear`));
# 3. Ask what happened this week.
curl -H "X-App-Id: $INDIE_APP_ID" -H "X-App-Secret: $INDIE_APP_SECRET" \
  "https://indie.fun/api/reports/progression?days=7" | jq '.summary, .funnel'

If game_start → first_move is where the players go, they are not bouncing off your level design. They are bouncing off a control scheme, a load time, or a menu.

Troubleshooting

Common cases

The funnel is emptyNo steps have been recorded. Check that progress() runs (set window.__MODDIO_DEBUG = true for SDK logs), and that the appId is the one on this game.
stepOrder is 'inferred'No order was requested and your game declares none, so steps were ordered by player count. Add progression: [...] to the SDK config, or pass steps=a,b,c.
missingEntryStep is largePlayers are reaching later steps without the first one. Your funnel's first step is not the first thing your game reports. Reorder it, or report it earlier.
A step has more players than the one before itThe order is wrong for how your game actually plays. Ask for the sequence explicitly with steps=.

Every field and endpoint is listed in the API reference.