Kraty

Events

Tournaments, races, and seasonal challenges.

An event is the central object in Kraty. It defines what players are competing on, how scores are computed, when it runs, and what gets handed out when it's over.

Anatomy

  • Metrics — the numbers you track (score, coins_collected, etc.).
  • Score formula — how metrics roll up into a leaderboard score.
  • Leaderboard mode — global, grouped, segmented, or lobby-matched.
  • Schedule — when the event starts and ends, and how repetition works.
  • Bot bindings — which bot definitions fill the board.
  • Reward policy — who gets what when the event finalizes.
  • Milestone rewards — optional mid-attempt payouts that fire the first time a watched metric crosses a threshold, independent of the terminal reward policy.

Metrics

Each metric you declare on the event becomes a number Kraty tracks per attempt. Beyond target / cap / scorePerUnit, two extras shape the player-facing semantics:

  • Cap@Target — clamp the capped value at target. The raw value keeps growing (analytics / anti-cheat still see overshoot); only the score-facing view is clamped.
  • Reset on — server-enforced reset: when a sibling metric goes up on a progress write, this metric is zeroed in the same call. Used for streaks. Example: a streak metric with Reset on: losses goes to zero the moment { losses: +1 } is written, even if the same write also tries to bump streak.

Metadata: baseline + per-window override

Every event carries a plain key/value metadata bag the SDK echoes back next to each event listing. Use it for game-side render hints — banner art keys, featured tier names, multipliers — without redeploying the client.

Two layers:

  1. Baseline — set on the event itself (Metadata baseline card in the editor). The default for every occurrence.
  2. Per-window override — set on a specific occurrence (Upcoming windows card). Overrides win for any keys they redefine; keys you don't override fall through to the baseline.

When the SDK reads an event listing it gets { ...event.metadata, ...window.metadata } — shallow merge, window keys win. There's no cross-window inheritance: window N+1 does NOT inherit from window N. Each occurrence starts fresh from the baseline. This is intentional — "what's in this window" is exactly what you typed for this window, plus baseline fallbacks. The editor has a Copy from previous button when you want to clone last cycle's overrides forward.

You setSDK sees
Baseline only: { tier: 'standard' }{ tier: 'standard' } on every window
Baseline { tier: 'standard' } + window override { tier: 'boss' }{ tier: 'boss' } on that window only
Baseline { tier: 'standard' } + window override { banner: 'lava' }{ tier: 'standard', banner: 'lava' } on that window
Nothing{}

Scheduling more occurrences ahead

By default the platform materializes one week of upcoming windows. That's plenty for a daily event but only ever surfaces the next occurrence of a weekly or monthly one — too few to pre-stage metadata overrides for the coming season. The Schedule horizon (days) field at the top of the Upcoming windows card lets you raise the lookahead per event (1–365 days). A weekly event with scheduleAheadDays: 60 surfaces ~8 upcoming rows, each with its own override slot. Leave the field empty to use the platform default.

current_window scope on unlock conditions

completed_event_at_least_once takes an optional within: 'lifetime' (default) or within: 'current_window'. The latter checks completions in the referenced event's currently-active shared window only — per-player (personal / local-calendar) windows are not considered. Combine with not to model "haven't won this event in this cycle yet."

Modes

ModeWhat it does
globalOne leaderboard for everyone.
groupedSharded leaderboards by player cohort.
segmentedPlayers placed into leagues (bronze → diamond).
lobby_matchedSmall ad-hoc lobbies, auto- or externally-matched.

Contributing to shared leaderboards

The per-event leaderboard above is window-scoped: it's born when the event opens and finalizes when it closes. Some boards need to outlive windows — a weekly global ladder, a monthly season, an all-time top 100. Those are configured under the Leaderboards tab of your game (see Leaderboards) and an event opts into them by listing their keys in contributesTo:

{
  "leaderboard": { "mode": "global", "scoreAggregation": "best" },
  "contributesTo": ["weekly_global", "season_3_kills"]
}

Every events.progress call dual-writes the score: once to the event's own leaderboard, then once to each shared board. Each shared board uses its own scoreAggregation — so the per-event board can be best while a companion weekly board sums totals across attempts, all from one wire call. Unknown or archived keys are silently dropped — a freshly-archived board never 500s the SDK, and stale bindings surface as warnings in the event editor.

Lifecycle

Events move through draft → scheduled → live → finalized. You can pause an event mid-run; finalization is the one-way door that triggers grants.

Preview

The event editor has a Preview section at the bottom. Plug in hypothetical final metric values and a simulated rank, then hit Run preview to see:

  • The score the formula computes (with caps applied).
  • Whether those metrics would mark the event complete.
  • The grant batch the reward policy would roll for that outcome.

Nothing is persisted — no attempt rows, no grants, no webhooks. Use it to sanity-check a scoreFormula tweak or to see what the top-rank payout looks like before flipping an event live.

Worked example: "Trail of Triumph"-style ladder

A 24-hour daily event where the player needs 5 consecutive wins to claim a shared prize pool, restarting their streak on any loss, with 49 bots climbing the same ladder. The shape pulls together four platform primitives:

{
  "key": "trail_of_triumph",
  "availability": {
    "mode": "recurring_windows",
    "timezone": "America/New_York",
    "windows": [
      { "startTime": "20:00", "durationSeconds": 86400, "daysOfWeek": [0,1,2,3,4,5,6] }
    ]
  },
  "leaderboard": { "mode": "lobby_matched", "capacity": 50, "scoreAggregation": "best" },
  "attempt": { "durationSeconds": 86400, "replayableDuringWindow": true },
  "metrics": [
    { "key": "streak", "target": 5, "capAtTarget": true,
      "resetOn": { "metricKey": "losses" } },
    { "key": "losses" }
  ],
  "scoreFormula": { "type": "linear" },
  "entryRequirement": {
    "type": "not",
    "condition": {
      "type": "completed_event_at_least_once",
      "eventKey": "trail_of_triumph",
      "within": "current_window"
    }
  },
  "rewardPolicy": {
    "type": "shared_pool",
    "parameters": {
      "pool": 10000,
      "currencyKey": "cash",
      "winnerPredicate": {
        "type": "metric_at_least",
        "metricKey": "streak",
        "threshold": 5
      }
    }
  },
  "botBindings": [
    { "botId": "<bot-with-random_step_with_fall>", "count": 49 }
  ]
}

How it composes:

  • resetOn wipes the streak when the client posts { losses: +1 } — server-enforced, so the client can't "preserve" the streak across a loss by reordering writes.
  • entryRequirement with within: 'current_window' blocks re-entry once the player has a completed attempt in today's window. The next day's window resets the gate naturally.
  • shared_pool reward policy waits for the window to close, then divides 10k cash evenly among everyone with streak >= 5. Per-attempt reward = none; the prize materializes at close as one grant per winner with sourceKind: "event_window".
  • random_step_with_fall bot block ticks once per player progress write — each bot deterministically advances by 1 or falls back to 0, and freezes once it reaches 5.

See the REST API reference for the full event schema.

Entry costs (paid events)

Distinct from entryRequirement (a binary ownership gate), an event can declare an entryCost that's atomically debited from the player's wallet and inventory when they call events.start. If the player can't afford it, the start fails with insufficient_entry_cost and the transaction rolls back — partial debits never persist.

{
  "key": "bounty_hunt",
  "type": "single_metric",
  "entryCost": {
    "currencies": [{ "key": "cash", "amount": 50 }],
    "items":      [{ "key": "bullet_basic", "quantity": 1 }]
  },
  "metrics": [{ "key": "bounties", "target": 5, "capAtTarget": true }],
  "rewardPolicy": {
    "type": "fixed_bundle",
    "parameters": { "rewardBundleId": "<bounty-hunt-payout-bundle>" }
  }
}

At runtime:

try {
  await kraty.events.start('player_42', 'bounty_hunt');
  // 50 cash and 1 bullet have been debited atomically.
} on KratyApiError catch (err) {
  if (err.isInsufficientEntryCost) {
    showInsufficientResourceDialog(err.message); // "not enough cash to enter — need 50"
  }
}

Cost vs requirement at a glance:

FieldSemanticsConsumed?Error code
entryRequirementBinary check — "must own item X"No, just verifiedentry_requirement_failed (403)
entryCostTransactional — "spend X to play"Yes, atomically debitedinsufficient_entry_cost (402)

The two compose — an event can require ownership AND charge a fee. Idempotency: a stable key derived from (eventWindow, player) ensures retried start calls don't double-charge.

The Flutter SDK surfaces the cost on the EventListing:

final events = await kraty.events.listForPlayer('player_42');
for (final e in events) {
  if (e.entryCost != null && !e.entryCost!.isEmpty) {
    print('${e.eventKey} costs:');
    for (final c in e.entryCost!.currencies) {
      print('  ${c.amount} ${c.key}');
    }
    for (final i in e.entryCost!.items) {
      print('  ${i.quantity}× ${i.key}');
    }
  }
}

Use this to render lock state in the events list — gray out paid events the player can't afford with a "need X, have Y" hint, so the player isn't surprised by a 402 when they tap Start.

Anti-cheat hooks

Events can declare server-side validators that run on every events.progress write. Each validator inspects the incoming update against the attempt's prior state and returns a verdict:

  • allow — no-op. The progress write applies normally.
  • flag — the progress write applies, but an anomaly is recorded AND an event.attempt_flagged webhook fires. Your backend decides what to do (manual review, auto-ban after N flags, log to analytics).
  • reject — the progress write is rolled back; the client gets 422 anti_cheat_rejected with the validator's reason.

Configure on the event:

{
  "antiCheat": {
    "validators": [
      {
        "key": "max_metric_rate",
        "params": { "metricKey": "score", "maxPerSecond": 1000 }
      },
      {
        "key": "max_metric_jump",
        "params": { "metricKey": "score", "maxDelta": 5000, "verdict": "reject" }
      },
      {
        "key": "min_attempt_duration",
        "params": { "minSeconds": 10, "metricKey": "score", "target": 1000, "verdict": "flag" }
      }
    ]
  }
}

Built-in validators:

KeyWhat it checks
max_metric_rateAverage per-second growth of a metric over the attempt's lifetime. Catches sustained-too-fast runs.
max_metric_jumpSingle-write absolute or relative cap on metric growth. Catches the "client sent score=10000 in one POST" pattern.
min_attempt_durationFloor on wall-clock duration to reach the target. Catches replay-bot patterns that complete in 1–2 seconds.

Each validator returns its verdict per call; validators run in declaration order. The strongest verdict wins (reject > flag > allow). All flags are recorded; a reject short-circuits the rest of the list.

Flagged + rejected events surface in the portal's Player Lookup screen under an "Anti-cheat anomalies" card so support staff can see the audit trail without leaving the page. The event.attempt_flagged webhook carries the same validatorKey, reason, and metricSnapshot so your backend can mirror the record into your own analytics or cheat-detection pipeline.