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