Kraty

Events

Tournaments, races, and seasonal challenges. The central object in Kraty, with metrics, score formulas, schedules, rewards, and anti-cheat hooks.

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

You configure events visually in the portal, but every field on this page maps directly to the wire shape your client and server SDKs see. For the exact listing shape the SDK receives, see the SDK API reference.

Anatomy of an event

FieldWhat it does
MetricsThe numbers you track per attempt (score, coins_collected, etc.).
Score formulaHow metrics roll up into a leaderboard score.
Leaderboard modeGlobal, grouped, segmented, or lobby-matched.
ScheduleWhen the event runs and how it repeats.
Bot bindingsWhich bot definitions fill the board.
Reward policyWho gets what when the event finalizes.
Milestone rewardsOptional mid-attempt payouts that fire the first time a watched metric crosses a threshold, independent of the terminal reward policy.

Trusting the clock (server time)

Event windows end at a real UTC instant (endsAt). If your game counts down using the device clock, a player can win "beat the timer" rewards by simply setting their phone clock back. Don't trust the device clock for anything that gates rewards — anchor to the server instead.

The SDK exposes the backend clock:

  • getServerTime() — a one-shot fetch. epochMs is UTC; compare it against the event's endsAt. Pass a timezone (e.g. getServerTime({ timezone: 'Europe/Lisbon' }), GetServerTimeAsync("Europe/Lisbon")) to also get that zone's wall-clock for display.
  • syncTime() + serverNow() — the recommended path for a live countdown. syncTime() fetches the server time once and pins it to a monotonic clock, so serverNow() keeps ticking accurately even if the player changes their device clock afterwards. Call syncTime() at startup and again on app resume, then drive your timer from serverNow().
await kraty.syncTime();                 // once at startup (and on resume)
const remainingMs = event.window.endsAt.getTime() - kraty.serverNowMs();
await KratyService.SyncTimeAsync();      // Unity
var remaining = eventWindow.EndsAt - KratyService.ServerNow();

Comparisons should always use the UTC epochMs / serverNow(); the timezone fields are for showing a local time to the player, nothing else.

Metrics

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

  • Cap-at-target: clamp the capped value at target. The raw value keeps growing (analytics and 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 by clicking its day in the Upcoming windows calendar and editing the overrides in the dialog that opens. Overrides win for keys they redefine; keys you do not override fall through to the baseline.

When the SDK reads an event listing it gets { ...event.metadata, ...window.metadata }, a shallow merge where window keys win.

There is no cross-window inheritance: window N+1 does NOT inherit from window N. Each occurrence starts fresh from the baseline. This is intentional: "what is 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{}

Pre-staging overrides ahead of time

The Upcoming windows calendar projects every occurrence directly from the event's availability config, so you do not have to wait for the scheduler to materialize a window before you can configure it. Navigate to any future month and click an occurrence to set its override; a window row is written only when you save one, and clearing it back to empty removes the row again. Occurrences that have already started (and past ones) are read-only.

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 "have not 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 leaderboards

The per-event leaderboard above is window-scoped: it is 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 leaderboard. Each leaderboard 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, so 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.

How an attempt ends

A player's attempt is a single run at the event, and it finalizes in one of three ways:

  1. It hits the completion target. A progress write that meets the event's completion target auto-completes the attempt: status flips to completed, completion rewards roll, and an event.completed webhook fires. No extra call needed.
  2. The window closes (or a timed attempt's duration elapses). The server finalizes the attempt as expired at window close and rolls any participation rewards.
  3. The player finishes it now. For an untimed score-attack event (no completion target, the player decides when the run is over), the client explicitly finishes the attempt with its current score. Without this, such an attempt would just sit there until the window closes.

Finishing an attempt

events.finish finalizes the in-progress attempt at its current score and returns an outcome telling you which terminal state it landed in:

const { attempt, outcome } = await kraty.events.finish(eventKey, attemptId);
// outcome: 'completed' | 'expired'
if (outcome === 'completed') {
  await kraty.grants.collectAll(); // completion rewards rolled
}
  • completed: the event has no completion target (score-attack), OR the target was already met. Completion rewards roll now; pull them with grants.collectAll().
  • expired: the event has a target that was not met, so the player ended early. Participation rewards only, the same outcome as a timeout.

Untimed score-attack events (no completion target, no attempt duration) require finish; it is the only thing that ends the run before the window closes. Timed and target-based events end on their own, but can still be finished early to settle the score immediately.

finish is rejected on session / lobby events (those end via their session rules, see below) and on an attempt that is already finished.

Sessions & sudden-death

By default a player gets one run at an event. Set attempt.maxAttemptsPerWindow to let them retry: a number for a fixed budget, or null for unlimited. Each run is a session: a self-contained mini-event with its own leaderboard, bots, rewards, and end condition, living inside the parent event.

A session can end early on a condition instead of only at the clock. Configure it under session.end.trigger:

TriggerEnds the session when…
first_to_scoreanyone reaches score.
first_attempt_completedanyone satisfies the objective.
completions_reachcount players have completed.
roster_fullevery seat is filled (matchmade).
idle_forno progress for seconds.
any / allcompose the above (OR / AND).

session.end.onEnd decides what happens to players still mid-run: freeze (settle at their current score, the default), grace_period (a few more seconds, then freeze), or discard (only the winner counts). A wall-clock backstop is always implied: a session can never outlive the event window, and no score is accepted past the window end.

Each player's sessions converge into the event's main leaderboard via session.convergence.scoreAggregation: best (your top session ranks you), sum (total across sessions), or latest. Solo vs multiplayer is just the session board's leaderboard config: lobby_matched with capacity: 1 is a solo lobby; a larger capacity matchmakes players (bots fill empty seats).

{
  "type": "single_metric",
  "attempt": { "durationSeconds": 3600, "maxAttemptsPerWindow": null, "concurrency": "sequential" },
  "metrics": [{ "key": "points", "target": 5 }],
  "scoreFormula": { "type": "linear", "parameters": { "scorePerUnit": 1 } },
  "leaderboard": { "mode": "lobby_matched", "capacity": 1, "fillBots": false },
  "session": {
    "end": {
      "trigger": { "type": "first_to_score", "score": 5 },
      "onEnd": { "inProgress": "freeze", "rewardEligibility": "as_completed" }
    },
    "convergence": { "scoreAggregation": "best", "mainBoard": { "mode": "global", "botCount": 20 } }
  }
}

A 24-hour event where a player spins up unlimited 1-hour solo sessions, each ending the instant they hit 5 points; their best session ranks them on the event-wide board.

When a session (or the whole event) ends, subscribed clients get a finalized stream event with final standings, so you can show the player their placement without polling.

Promotion & relegation

An event board can nudge a progression item up or down by finishing position when it finalizes, the same league-ladder mechanic as leaderboards. Add progressionAdjustments to your rewardPolicy.parameters:

"rewardPolicy": {
  "type": "rank_scaled",
  "parameters": {
    "progressionAdjustments": [
      { "end": "top",    "fromPlace": 1, "toPlace": 1, "itemKey": "leaderboardlevel", "amount":  1, "ceiling": 10 },
      { "end": "bottom", "fromPlace": 1, "toPlace": 3, "itemKey": "leaderboardlevel", "amount": -1, "floor": 0 }
    ]
  }
}

Promotions are capped by ceiling, relegations floored by floor. Segment the board by that same progression item and players climb and drop divisions each time the event runs. See Promotion & relegation ladders.

Preview

The event editor has a Preview section at the bottom. Plug in hypothetical final metric values and a simulated rank, then click 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: a "Trail of Triumph" 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 cannot "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 is 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 conventions for the authoring conventions every event field follows.

Entry costs (paid events)

Distinct from entryRequirement (a binary ownership gate), an event can declare an entryCost that is atomically debited from the player's wallet and inventory when they call events.start. If the player cannot 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 your client catches the shortfall and surfaces it gracefully:

try {
  await kraty.events.start('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 do not double-charge.

The Flutter SDK surfaces the cost on the EventListing so you can render lock state in the events list, graying out paid events the player cannot afford with a "need X, have Y" hint, so the player is not surprised by a 402 when they tap Start:

final events = await kraty.events.listForPlayer();
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}');
    }
  }
}

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 and 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.

Server-authoritative scoring

Anti-cheat validators inspect client writes after the fact. The stronger posture is to not let the client write scores at all; have your trusted backend report progress instead. Two pieces make this work.

The acceptClientScores gate

Every event carries an acceptClientScores flag (default on), set with the Accept client scores toggle in the event editor. It decides whether a client_sdk key (the key shipped inside your game) may write progress:

acceptClientScoresClient SDK (client_sdk key)Server SDK / API (server_integration key)
on (default)events.progress allowedallowed
off403 client_scoring_disabledallowed

With the gate off, a tampered client physically cannot push progress; events.start still works (the player enters the event), but every events.progress from a client key returns 403 client_scoring_disabled. Your backend, holding a server_integration key, reports the authoritative result. Use this for ranked tournaments, prize events, and anything where a faked score has real value.

The same flag exists on leaderboards; see The acceptClientScores gate on the leaderboards page. They are independent toggles: a board can accept client scores while a sensitive event does not.

Reporting progress from your server

When the gate is off (or whenever you simply prefer server-authoritative scoring), report progress with the server SDK. It targets the same attempt the client started, by attemptId:

import { KratyServer } from '@kraty/server-sdk';

const kraty = new KratyServer({ apiKey: process.env.KRATY_SERVER_KEY! });

// `mode: 'set'` writes the value; `'increment'` adds to the current.
const result = await kraty.events.reportProgress(
  'player_42',     // external player id
  'bounty_hunt',   // event key
  attemptId,       // the attempt the player started client-side
  { mode: 'increment', metricValue: 1 },
);
for (const fired of result.milestonesFired) {
  // same milestone payload the client path returns
}
result = kraty.events.report_progress(
    "player_42",     # external player id
    "bounty_hunt",   # event key
    attempt_id,      # the attempt the player started client-side
    mode="increment",
    metric_value=1,
)
for fired in result["milestonesFired"]:
    ...  # same milestone payload the client path returns

reportProgress returns the updated attempt plus any milestones that fired this call, the same shape the client's events.progress returns. Direct leaderboard writes have an equivalent server path; see Scoring a leaderboard directly.