Skip to content

Progression Counters

Progression counters are meters. They accumulate across gamerounds. When a counter reaches 1.0, it triggers an award (feature spins, cash, or a choice between the two for the player). The generator records, on each entry, which counters it moves and by how much (progressionAwards). The generator also writes the matching counter configuration into config.json (progressionCounters).

This page covers how to define progression counters at generation time. See SDK · Progression Counters for how the engine accumulates, completes, and resets them at runtime.

Two parts of a progression counter

A progression counter in the generator has two pieces:

  1. Entry-level increments (progressionAwards, passed to addResult()): each entry that moves a counter carries a fractional increment. The generator keys this increment by counter name and by the 0-based scenario step that produced it. The engine reads this data to move the meter as outcomes land.
  2. Counter config: an IProgressionCounterConfig attached to config.progressionCounters in config.json. This config tells the engine four things: the counter's name, what it awards on completion, whether it is per-stake, and whether a feature ending resets it.

Use both. Increments with no config never complete. A config with no incrementing entries never fills.

Recording increments with addResult()

Pass progressionAwards alongside the usual win / metaTags / featureAwards options. The outer key is the counter's name. The inner key is the 0-based step index within this call's scenario array (0 for a single-object scenario). The generator attributes the increment to this step:

typescript
// A single scatter land increments 'scatter-collection' by 1%.
generator.addResult(
  { reels: [7, 0, 0] },
  {
    feature: 'basegame',
    win: 0,
    metaTags: ['scatter'],
    progressionAwards: { 'scatter-collection': { 0: 0.01 } },
  },
);

For a multi-result scenario (an array passed to addResult()), attribute each increment to the step that caused it. Do not bundle everything onto step 0:

typescript
// Step 0 is a plain tumble; step 2 lands the third scatter of a cascade.
generator.addResult(
  [{ grid: gridAfterDrop1 }, { grid: gridAfterDrop2 }, { grid: gridAfterDrop3 }],
  {
    feature: 'basegame',
    win: 12,
    progressionAwards: { 'scatter-collection': { 2: 0.03 } },
  },
);

Per-step attribution lets the runtime stamp the counter change onto the exact scenario step that produced it. The client can then animate the fill (and any award) at the right moment, instead of only at round end. See engineData.progressionEvents on the SDK page.

Entry matching includes progressionAwards and progressionInfo

The generator merges addResult() calls into one entry when feature, win, metaTags, featureAwards, progressionAwards, and progressionInfo all match. Two otherwise-identical outcomes that increment different counters (or by different amounts) become two separate entries. The same holds for two outcomes whose progressionInfo differs. Keep the increment granularity coarse (round to a fixed step size) if you want counter-driven outcomes to merge instead of multiplying your entry count.

The generator can increment a counter from any feature's entries. For example, a scatter that lands during freespin can feed the same counter as one that lands in basegame. The generator does not require the incrementing entries to share a feature with the counter's award.

Counter config: IProgressionCounterConfig

Pass progressionCounters inside config at end(). Selection mode (randomChoice or playerChoice) lives on onComplete. Each option inside onComplete.awards is independently a feature-spin award ({ count, feature }) or a cash award ({ winMultiplier }). You can mix the two kinds of award in the same array.

typescript
await generator.end({
  config: {
    gameCode: 'my-slot',
    gameType: 'slot',
    stakes: [1.0],
    features: ['freespin'],
    progressionCounters: [
      {
        name: 'scatter-collection',
        onComplete: {
          type: 'randomChoice',
          awards: [{ count: 10, feature: 'freespin' }],
        },
        stakeSpecific: false,
      },
    ],
  },
});

stakeSpecific

When true, the engine keeps one counter value per stake level. Otherwise, the engine shares a single value across all stakes a player switches between.

resetOnFeatureEnd

IProgressionCounterConfig also carries an optional resetOnFeatureEnd?: string[]. This field lists feature names. When one of these features ends, the counter would reset to 0. This reset would discard any carried value.

Not enforced by the current runtime

The hizi-engine runtime does not act on this field today. A previous version implemented this feature, then removed it. Nothing resets the counter when a listed feature ends. Also, loadConfig strips the field before it reaches the client. A counter with resetOnFeatureEnd set behaves the same as one without it in a live game. It simply persists across rounds until it completes.

The KPI-math RTP calculation (computeGameRtp()) still honours resetOnFeatureEnd via progressionInfo (see below). This means a config that sets resetOnFeatureEnd can produce an RTP report that assumes reset behaviour. The live game never performs this reset behaviour. Do not set resetOnFeatureEnd on a shipping game's progressionCounters. For a meter that fills and resets within a single feature run or gameround, use Single-Gameround Jackpots instead. This is the currently-supported mechanic for that shape.

progressionInfo: explicit reset boundaries for the analytic RTP model

addResult() also accepts an optional progressionInfo alongside progressionAwards. This field provides explicit metadata about where a multi-step entry's feature runs end. It also provides a flat list of the entry's counter increments by step. The progressionInfo field exists so the KPI math never has to infer feature-run boundaries from scenario data. This field is an offline, analytic-RTP input. As covered above, the live runtime does not currently reset counters on a feature end. So progressionInfo has no effect on gameplay:

typescript
generator.addResult(scenarioSteps, {
  feature: 'freespin',
  win: 40,
  progressionAwards: { 'bonus-jackpot': { 4: 0.25 } },
  progressionInfo: {
    featureEnds: [{ name: 'freespin', scenarioIndex: 9 }],
    progressionEvents: [{ counter: 'bonus-jackpot', increment: 0.25, scenarioIndex: 4 }],
  },
});

progressionInfo does change the generated output

progressionInfo has no effect on gameplay, but it is not free of consequences. The generator folds it into the entry match key and into the looseScenarioCap bucket key. Two otherwise-identical outcomes with different progressionInfo therefore become two separate entries, each with its own weight and its own scenario budget. Bake it consistently, and keep it as coarse as the RTP model allows, or the entry count multiplies.

This matters specifically for resetOnFeatureEnd counters. Without a baked reset boundary, computeGameRtp() would have to assume that all increment mass eventually pays out. This assumption overstates RTP for a counter whose progress is sometimes discarded mid-run. progressionInfo is optional. Omit it for counters that never use resetOnFeatureEnd, because there is no reset boundary to bake.

Full example

A minimal simulation with a freespin feature and a scatter-collection counter that awards 10 free spins on completion. This is runnable end-to-end.

typescript
import { HiziEngineGenerator } from '@hizi.io/engine-generator';

const gen = new HiziEngineGenerator();
await gen.start('./output/');

// ── Basegame ──
for (let i = 0; i < 700; i++)
  gen.addResult({ reels: [0, 0, 0] }, { feature: 'basegame', win: 0, metaTags: ['no-win'] });
for (let i = 0; i < 200; i++)
  gen.addResult({ reels: [1, 1, 0] }, { feature: 'basegame', win: 2, metaTags: ['small-win'] });
// Every scatter land increments the counter by 1% - 100 lands to complete.
for (let i = 0; i < 100; i++)
  gen.addResult(
    { reels: [7, 0, 0] },
    {
      feature: 'basegame',
      win: 0,
      metaTags: ['scatter'],
      progressionAwards: { 'scatter-collection': { 0: 0.01 } },
    },
  );

// ── Freespin ──
for (let i = 0; i < 80; i++)
  gen.addResult({ reels: [0, 0, 0] }, { feature: 'freespin', win: 0, metaTags: ['no-win'] });
for (let i = 0; i < 20; i++)
  gen.addResult({ reels: [2, 2, 0] }, { feature: 'freespin', win: 3, metaTags: ['small-win'] });

await gen.end({
  config: {
    gameCode: 'progression-demo',
    gameType: 'slot',
    stakes: [1.0],
    features: ['freespin'],
    progressionCounters: [
      {
        name: 'scatter-collection',
        onComplete: {
          type: 'randomChoice',
          awards: [{ count: 10, feature: 'freespin' }],
        },
        stakeSpecific: false,
      },
    ],
  },
});

What gets written

  • entries.jsonl.br: the scatter entry carries "progressionAwards":{"scatter-collection":{"0":0.01}}. The generator does not create synthetic entries for counters (unlike buy-feature pools). The field lives directly on the entries that earn it.
  • config.json: includes progressionCounters with the config above.

See Output Format · Entry Format and Output Format · Config Format for the exact JSON shapes.

KPI math

The generator's KPI functions fold progression counters into RTP and effective-win calculations when you pass progressionCounters alongside your entries:

  • computeGameRtp() adds a cashCounterBreakdowns entry for every counter with at least one cash option in onComplete.awards, as long as the counter accumulates some increment mass. The function skips a counter that never accumulates anything anywhere. The function computes each entry from the expected completions and the expected cash per completion. Feature options need no special handling. The awarded spins land in the target feature's own DB, so its empirical RTP already includes them.
  • computeEffectiveEntries() folds each entry's counter increments into effectiveWin. Each increment contributes its share of the counter's expected payout (feature EV or cash winMultiplier, per option).

For a playerChoice counter, both functions default to assuming a uniform pick across onComplete.awards. This is not real player behaviour. Pass playerChoiceAnalysis: true for the true worst-case/best-case RTP range instead of a single blended number. See KPI Math · selectionFraction assumes uniform choice.