Events
Tournaments, races, and seasonal challenges.
An event is the central object in Kraty. It defines what players are competing on, how scores are computed, when it runs, and what gets handed out when it's over.
Anatomy
- Metrics — the numbers you track (
score,coins_collected, etc.). - Score formula — how metrics roll up into a leaderboard score.
- Leaderboard mode — global, grouped, segmented, or lobby-matched.
- Schedule — when the event starts and ends, and how repetition works.
- Bot bindings — which bot definitions fill the board.
- Reward policy — who gets what when the event finalizes.
- Milestone rewards — optional mid-attempt payouts that fire the first time a watched metric crosses a threshold, independent of the terminal reward policy.
Metrics
Each metric you declare on the event becomes a number Kraty tracks
per attempt. Beyond target / cap / scorePerUnit, two
extras shape the player-facing semantics:
- Cap@Target — clamp the capped value at
target. The raw value keeps growing (analytics / anti-cheat still see overshoot); only the score-facing view is clamped. - Reset on — server-enforced reset: when a sibling metric goes
up on a progress write, this metric is zeroed in the same call.
Used for streaks. Example: a
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 (Upcoming windows card). Overrides win for any keys they redefine; keys you don't override fall through to the baseline.
When the SDK reads an event listing it gets
{ ...event.metadata, ...window.metadata } — shallow merge, window
keys win. There's no cross-window inheritance: window N+1 does NOT
inherit from window N. Each occurrence starts fresh from the baseline.
This is intentional — "what's in this window" is exactly what you
typed for this window, plus baseline fallbacks. The editor has a
Copy from previous button when you want to clone last cycle's
overrides forward.
| You 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 | {} |
Scheduling more occurrences ahead
By default the platform materializes one week of upcoming windows.
That's plenty for a daily event but only ever surfaces the next
occurrence of a weekly or monthly one — too few to pre-stage
metadata overrides for the coming season. The Schedule horizon
(days) field at the top of the Upcoming windows card lets
you raise the lookahead per event (1–365 days). A weekly event
with scheduleAheadDays: 60 surfaces ~8 upcoming rows, each
with its own override slot. Leave the field empty to use the
platform default.
current_window scope on unlock conditions
completed_event_at_least_once takes an optional within: 'lifetime'
(default) or within: 'current_window'. The latter checks completions
in the referenced event's currently-active shared window only —
per-player (personal / local-calendar) windows are not considered.
Combine with not to model "haven't won this event in this cycle yet."
Modes
| 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 shared leaderboards
The per-event leaderboard above is window-scoped: it's born when the
event opens and finalizes when it closes. Some boards need to outlive
windows — a weekly global ladder, a monthly season, an all-time top
100. Those are configured under the Leaderboards tab of your game
(see Leaderboards) and an
event opts into them by listing their keys in contributesTo:
{
"leaderboard": { "mode": "global", "scoreAggregation": "best" },
"contributesTo": ["weekly_global", "season_3_kills"]
}Every events.progress call dual-writes the score: once to the
event's own leaderboard, then once to each shared board. Each shared
board uses its own scoreAggregation — so the per-event board
can be best while a companion weekly board sums totals across
attempts, all from one wire call. Unknown or archived keys are
silently dropped — a freshly-archived board never 500s the SDK,
and stale bindings surface as warnings in the event editor.
Lifecycle
Events move through draft → scheduled → live → finalized. You can pause an
event mid-run; finalization is the one-way door that triggers grants.
Preview
The event editor has a Preview section at the bottom. Plug in hypothetical final metric values and a simulated rank, then hit Run preview to see:
- The score the formula computes (with caps applied).
- Whether those metrics would mark the event complete.
- The grant batch the reward policy would roll for that outcome.
Nothing is persisted — no attempt rows, no grants, no webhooks. Use it to sanity-check a scoreFormula tweak or to see what the top-rank payout looks like before flipping an event live.
Worked example: "Trail of Triumph"-style ladder
A 24-hour daily event where the player needs 5 consecutive wins to claim a shared prize pool, restarting their streak on any loss, with 49 bots climbing the same ladder. The shape pulls together four platform primitives:
{
"key": "trail_of_triumph",
"availability": {
"mode": "recurring_windows",
"timezone": "America/New_York",
"windows": [
{ "startTime": "20:00", "durationSeconds": 86400, "daysOfWeek": [0,1,2,3,4,5,6] }
]
},
"leaderboard": { "mode": "lobby_matched", "capacity": 50, "scoreAggregation": "best" },
"attempt": { "durationSeconds": 86400, "replayableDuringWindow": true },
"metrics": [
{ "key": "streak", "target": 5, "capAtTarget": true,
"resetOn": { "metricKey": "losses" } },
{ "key": "losses" }
],
"scoreFormula": { "type": "linear" },
"entryRequirement": {
"type": "not",
"condition": {
"type": "completed_event_at_least_once",
"eventKey": "trail_of_triumph",
"within": "current_window"
}
},
"rewardPolicy": {
"type": "shared_pool",
"parameters": {
"pool": 10000,
"currencyKey": "cash",
"winnerPredicate": {
"type": "metric_at_least",
"metricKey": "streak",
"threshold": 5
}
}
},
"botBindings": [
{ "botId": "<bot-with-random_step_with_fall>", "count": 49 }
]
}How it composes:
resetOnwipes the streak when the client posts{ losses: +1 }— server-enforced, so the client can't "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 = 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 reference for the full event schema.
Entry costs (paid events)
Distinct from entryRequirement (a binary ownership gate), an event
can declare an entryCost that's atomically debited from the
player's wallet and inventory when they call events.start. If the
player can't afford it, the start fails with insufficient_entry_cost
and the transaction rolls back — partial debits never persist.
{
"key": "bounty_hunt",
"type": "single_metric",
"entryCost": {
"currencies": [{ "key": "cash", "amount": 50 }],
"items": [{ "key": "bullet_basic", "quantity": 1 }]
},
"metrics": [{ "key": "bounties", "target": 5, "capAtTarget": true }],
"rewardPolicy": {
"type": "fixed_bundle",
"parameters": { "rewardBundleId": "<bounty-hunt-payout-bundle>" }
}
}At runtime:
try {
await kraty.events.start('player_42', 'bounty_hunt');
// 50 cash and 1 bullet have been debited atomically.
} on KratyApiError catch (err) {
if (err.isInsufficientEntryCost) {
showInsufficientResourceDialog(err.message); // "not enough cash to enter — need 50"
}
}Cost vs requirement at a glance:
| 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 don't double-charge.
The Flutter SDK surfaces the cost on the EventListing:
final events = await kraty.events.listForPlayer('player_42');
for (final e in events) {
if (e.entryCost != null && !e.entryCost!.isEmpty) {
print('${e.eventKey} costs:');
for (final c in e.entryCost!.currencies) {
print(' ${c.amount} ${c.key}');
}
for (final i in e.entryCost!.items) {
print(' ${i.quantity}× ${i.key}');
}
}
}Use this to render lock state in the events list — gray out paid events the player can't afford with a "need X, have Y" hint, so the player isn't surprised by a 402 when they tap Start.
Anti-cheat hooks
Events can declare server-side validators that run on every
events.progress write. Each validator inspects the incoming
update against the attempt's prior state and returns a verdict:
allow— no-op. The progress write applies normally.flag— the progress write applies, but an anomaly is recorded AND 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 + 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.