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
StateWhich environments the event exists in: disabled, staging or live. See State below.
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, optionally split per division.
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.

Start from a template

You don't have to assemble an event section by section. + New event in the portal opens a gallery of ready-made liveops templates; pick one, give it a name, and it is created together with everything it depends on, then opens for tuning. Event templates are created in the staging state, so they are playable with a test API key immediately and invisible to real players until you promote them.

TemplateWhat you getMetric to report
Milestone trackThe solo workhorse. Players earn event points from normal play and climb a single track of five milestones inside a week-long window; each milestone pays out in a currency the moment it is crossed, and the last one (2,000 points) is the grand prize that completes the run. One attempt per player per week, no lobbies, no bots. The template asks which currency the milestones pay in: pick one of your existing currencies or create one.event_points, +N per level / match / delivery
RaceFive-seat lobbies that fill with bots the instant a player joins (one seat is always held for the player). The session ends the moment someone reaches 5 progress; every run adds to the player's total on a global event board. Runs Monday, Wednesday and Friday, 10:00–22:00 UTC. Ships with four rival bots: AFK (never moves), Crawler (climbs to 4, never wins), Quitter (rushes to 2, then stops) and Rubberbander (answers each of the player's steps after a short delay, never pulls ahead).progress, +1 per lap / level / checkpoint
League with divisionsA weekly promotion/relegation ladder. Players are bucketed by a progression item (their division), compete in cohorts of up to 20 real players topped up with 30 pace-setting bots, and at the Monday 00:00 UTC reset the top 5 move up one division and the bottom 10 drop one. The template asks for the division item: create a new one (10 single-step divisions, everyone starts in Division 1) or pick an existing progression item.points, summed across the week
Daily login streakA calendar of escalating rewards for consecutive days of play, built on the platform's own login_streak counter (server time, nothing to report from the game). Days 1 to 7 each pay a growing amount of a currency you pick, with the milestone on day 7; a missed day starts the calendar over, and after day 7 it starts again from day 1. This one sets up the game's daily login calendar (edit it under Live ops → Daily login), not a scheduled event. See Daily login.read the calendar with dailyLogin.read(); logins are counted by the platform

Picking a template asks for a name and, where it matters, the catalog item it runs on (pick one of yours or create it there). Advanced settings, folded away by default, expose the knobs each template has: division names and board sizes for the league, the milestone thresholds and payouts (with any extra item, crate, bundle or table rewards) for the track, the daily amounts for the login streak, the target and lobby size for the race. Event templates then ask when it runs: one-off with a start and an end, every day, or on chosen weekdays, at a local time in a timezone you pick, for a duration. Everything else about the schedule (every N weeks, end dates, per-player calendars) lives on the event's Schedule tab.

All of them are ordinary events afterwards: change the schedule, the target, the bots, the milestones or the ladder under the event's tabs like any other event. The league's promotion rules live under Rewards (see Promotion & relegation); if you keyed it on an existing item with wide tiers, that is where you retune how far a promotion moves.

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.

State: disabled, staging, live

Every event carries a state that decides which of your two API-key environments can reach it. It is the switch, and it is separate from the schedule, which is the clock.

StateTest API keysLive API keys
Disabled——
Stagingsee it, on its schedule—
Livesee it, on its schedulesee it, on its schedule
  • Disabled is the default for a new event, and it means off everywhere: no windows open, and neither your test build nor your players can see it. Use it to park an event you are not working on.
  • Staging is where a work-in-progress event lives. Your test keys play it exactly as a player would; live keys behave as if it did not exist.
  • Live exposes it to real players. Your test keys keep seeing it too, on their own isolated windows and leaderboards, so you can always reproduce what a player is hitting.

Archiving an event overrides all three — it disappears everywhere and frees its key for reuse.

Staging is not "always on". A staging event runs on its real schedule in your test environment: outside its window it is not listed and start returns no_active_window, the same answer a player would get. If you need to play it right now, use the Test tools on the event page — New instance opens a window immediately, and End now closes one. Those override the clock; nothing overrides the state.

Demoting an event takes effect immediately rather than at the end of the current window. Moving live → staging closes the running live window properly — leaderboards finalize, in-progress attempts settle and end-of-window rewards roll — and leaves the test window alone. Moving to disabled (or archiving) does the same for both.

Promoting never closes anything, including a window you opened off-schedule with New instance: flipping the switch upward only adds an environment, so the test run you are in the middle of keeps going and the newly-reached environment picks up its own windows on the next scheduler tick.

Lifecycle

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

Editing the schedule of a running event

Kraty pre-creates upcoming occurrences, so changing an event's schedule reconciles the ones already on the books:

  • Upcoming occurrences the new schedule no longer produces are cancelled — they will not open.
  • Occurrences that keep their start but change length are re-timed in place.
  • If the occurrence that is live right now is no longer on the schedule, it ends immediately: leaderboards finalize, in-progress attempts settle, end-of-window rewards roll, and window.closed fires. This is a real finalization, not a silent cancel.

Changing the timezone moves every occurrence, so it falls in that last bucket: expect the running occurrence to close. Re-saving the same schedule changes nothing, so editing rewards or metadata on a live event is safe.

Timezones are IANA names (Europe/Lisbon, Asia/Tokyo) — not PST or CET. For a one-off Exact window, the start and end you type are wall-clock times in the timezone you picked, stored as absolute instants; changing the timezone afterwards moves the event rather than relabelling it. The Display timezone in game settings is unrelated: it only formats timestamps in the dashboard.

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.

Attempt limits

Two independent caps decide how often a player may enter, both set under attempt:

FieldLimits
maxAttemptsPerWindowentries for the whole window.
maxAttemptsPerDayentries per local calendar day inside that window.

Leave either unset for unlimited. When both are set, whichever is tighter right now wins.

These are total entries, not extra retries. maxAttemptsPerDay: 2 means two plays per day — the first play and one more — not one play plus two retries. An entry is spent the moment an attempt starts, so a player's live run already counts against the cap.

The day boundary is the local calendar day in the event's availability.timezone (UTC when unset), so a weekend-long window can throttle "2 entries per day" without cutting anyone off at an odd hour.

Showing "1/2" in your UI

Every event in the listing carries an attemptQuota so you can render the counter and disable Play before the player taps it:

{
  "eventKey": "wild_race",
  "attemptQuota": {
    "maxPerWindow": null,
    "usedInWindow": 0,
    "maxPerDay": 2,
    "usedToday": 1,
    "remaining": 1
  }
}
const events = await kraty.events.listForPlayer();
for (const e of events) {
  const q = e.attemptQuota;
  const label =
    q?.maxPerDay != null
      ? `Play (${q.usedToday}/${q.maxPerDay} today)`
      : 'Play';
  renderPlayButton({ label, disabled: q?.remaining === 0 });
}

remaining is the tightest configured cap, or null when the event caps nothing. A counter is only filled in for a cap that exists: an uncapped event reports 0 used against a null maximum.

Starting past a cap fails with 429 and a distinct code — max_attempts_reached for the window cap, max_daily_attempts_reached for the daily one — so you can say "come back tomorrow" rather than "out of entries". A refused start has no side effects: nothing is charged, no lobby or session is created, and the player joins no leaderboard.

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.

Late joiners fill running sessions

A player who arrives after a matchmade session started joins it if it still has a free seat and enough time left, instead of opening a half-empty second lobby. Bots count as occupants, so a 32-seat session holding 1 player + 30 bots has exactly one seat, and the next arrival takes it.

Two guards stop someone dropping into the closing seconds of a short match: at least 30 seconds and at least 25% of the session must remain. That leaves a week-long session open for essentially its whole life while keeping a 60-second race closed near the end. Both are configurable on the leaderboard config, and backfill: { "enabled": false } restores the old "every late arrival opens a new lobby" behaviour:

{
  "leaderboard": {
    "mode": "lobby_matched",
    "capacity": 32,
    "backfill": { "enabled": true, "minRemainingSeconds": 30, "minRemainingFraction": 0.25 }
  }
}

For a persistent divisional board — everyone in Silver on one weekly leaderboard — use a segmented event with a player cap rather than lobby_matched. See segmented leaderboards.

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.

Per-division overrides. Give one league its own ladder with a perSegmentProgressionAdjustments map (keyed by the division's segment value) — a listed division uses its own rules, the rest fall back to the global ones, and an empty rule list means "no moves in this division" (e.g. no relegation out of the bottom league). The event editor's Reward policy section has a per-division editor for this. Your game can read the resolved cutoffs off a board read's progression field, so "top N promote / bottom N relegate" comes straight from server config — no client change when you retune the ladder.

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.