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
| Field | What it does |
|---|---|
| State | Which environments the event exists in: disabled, staging or live. See State below. |
| Metrics | The numbers you track per attempt (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 runs and how it repeats. |
| Bot bindings | Which bot definitions fill the board, optionally split per division. |
| 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. |
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.
| Template | What you get | Metric to report |
|---|---|---|
| Milestone track | The 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 |
| Race | Five-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 divisions | A 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 streak | A 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.epochMsis UTC; compare it against the event'sendsAt. 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, soserverNow()keeps ticking accurately even if the player changes their device clock afterwards. CallsyncTime()at startup and again on app resume, then drive your timer fromserverNow().
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
streakmetric withReset on: lossesgoes to zero the moment{ losses: +1 }is written, even if the same write also tries to bumpstreak.
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:
- Baseline: set on the event itself (Metadata baseline card in the editor). The default for every occurrence.
- 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 set | SDK 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
| Mode | What it does |
|---|---|
global | One leaderboard for everyone. |
grouped | Sharded leaderboards by player cohort. |
segmented | Players placed into leagues (bronze → diamond). |
lobby_matched | Small 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.
| State | Test API keys | Live API keys |
|---|---|---|
| Disabled | — | — |
| Staging | see it, on its schedule | — |
| Live | see it, on its schedule | see 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.closedfires. 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:
- It hits the completion target. A
progresswrite that meets the event's completion target auto-completes the attempt: status flips tocompleted, completion rewards roll, and anevent.completedwebhook fires. No extra call needed. - The window closes (or a timed attempt's duration elapses).
The server finalizes the attempt as
expiredat window close and rolls any participation rewards. - 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 withgrants.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:
| Field | Limits |
|---|---|
maxAttemptsPerWindow | entries for the whole window. |
maxAttemptsPerDay | entries 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:
| Trigger | Ends the session when… |
|---|---|
first_to_score | anyone reaches score. |
first_attempt_completed | anyone satisfies the objective. |
completions_reach | count players have completed. |
roster_full | every seat is filled (matchmade). |
idle_for | no progress for seconds. |
any / all | compose 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:
resetOnwipes the streak when the client posts{ losses: +1 }, server-enforced, so the client cannot "preserve" the streak across a loss by reordering writes.entryRequirementwithwithin: '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_poolreward policy waits for the window to close, then divides 10k cash evenly among everyone withstreak >= 5. Per-attempt reward is none; the prize materializes at close as one grant per winner withsourceKind: "event_window".random_step_with_fallbot 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:
| Field | Semantics | Consumed? | Error code |
|---|---|---|---|
entryRequirement | Binary check ("must own item X") | No, just verified | entry_requirement_failed (403) |
entryCost | Transactional ("spend X to play") | Yes, atomically debited | insufficient_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 anevent.attempt_flaggedwebhook 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 gets422 anti_cheat_rejectedwith 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:
| Key | What it checks |
|---|---|
max_metric_rate | Average per-second growth of a metric over the attempt's lifetime. Catches sustained-too-fast runs. |
max_metric_jump | Single-write absolute or relative cap on metric growth. Catches the "client sent score=10000 in one POST" pattern. |
min_attempt_duration | Floor 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:
acceptClientScores | Client SDK (client_sdk key) | Server SDK / API (server_integration key) |
|---|---|---|
| on (default) | events.progress allowed | allowed |
| off | 403 client_scoring_disabled | allowed |
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 returnsreportProgress 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.