Find out where players stop playing, and how long they lasted before they did.
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.
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.
_ - . : /, up to 64 characters. Commas are not allowed. They separate steps in the report query.tutorial_done rather than tutorial. You want to know who finished it, not who saw it.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.
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.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']);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.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./api/me/creations/[gameId]/progression, on your session cookie.{
"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": "..." }
}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).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.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.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.days=7 before and after a release. A step whose conversion moved is a step your release touched.steps=. The declared order is a default, not a limit. Ask for any sequence of steps you have recorded.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.
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.