@kradle/challenges-sdk 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM_README.md CHANGED
@@ -6,15 +6,50 @@ This document provides exhaustive API documentation for AI agents using the `@kr
6
6
 
7
7
  1. [Overview](#overview)
8
8
  2. [Installation](#installation)
9
- 3. [Core Concepts](#core-concepts)
10
- 4. [createChallenge API](#createchallenge-api)
11
- 5. [Variables](#variables)
12
- 6. [Events](#events)
13
- 7. [Actions](#actions)
14
- 8. [Utilities](#utilities)
15
- 9. [Sandstone Integration](#sandstone-integration)
16
- 10. [Complete Examples](#complete-examples)
17
- 11. [Common Patterns](#common-patterns)
9
+ 3. [Helpers — start here](#helpers--start-here)
10
+ 4. [Core Concepts](#core-concepts)
11
+ 5. [createChallenge API](#createchallenge-api)
12
+ 6. [Variables](#variables)
13
+ 7. [Events](#events)
14
+ 8. [Actions](#actions)
15
+ 9. [Utilities](#utilities)
16
+ 10. [Sandstone Integration](#sandstone-integration)
17
+ 11. [Complete Examples](#complete-examples)
18
+ 12. [Common Patterns](#common-patterns)
19
+ 13. [Common Gotchas](#common-gotchas)
20
+
21
+ ---
22
+
23
+ ## Helpers — start here
24
+
25
+ These compress arena geometry / detection patterns that show up in nearly every challenge. Reach for them before writing raw `Actions.fill` loops or `execute if block` chains.
26
+
27
+ | Helper | What it does | Use instead of |
28
+ |--------|--------------|----------------|
29
+ | **`Actions.box(...)`** | Stone floor + 4 walls (or full hollow box, with optional roof / interior clearing) — single call. Full reference below in [World](#world). | Five separate `Actions.fill` calls per arena |
30
+ | **`Actions.flatArena({ block, center, radius, headroom? })`** | Single-block-thick floor + cleared airspace above, no walls. The "open ground for things to move on" pattern from boss-brawl, sumo-arena, wave-survival, stalker. | Two manual `Actions.fill` calls (one stone, one air) per arena |
31
+ | **`Actions.countEntities({ entity?, tag?, volume? }): Score`** | Counts entities matching any combination of `entity` type, `tag`, and/or `volume`. Returns an anonymous `Score` — the caller assigns it into their custom_variable (`value.set(Actions.countEntities({ entity: "minecraft:zombie" }))`). At least one filter must be provided (counting "every entity in the world" throws at build). Drop-in replacement for the `value.set(0); execute.as(Selector("@e", { type })).run(() => value.add(1))` pattern that shows up in 8+ challenges' custom_variable updaters. | Hand-rolled `execute.as(Selector(...)).run(...)` chain |
32
+ | **`Actions.countBlocksInRegion(...)` / `Actions.countBlocksRelativeTo(...)`** | Counts a block ID across a 3D region; returns a `Score` you can drive end-states off. | `_.if(_.block(abs(x,y,z), ...))` chains across many cells |
33
+ | **`Actions.maxPlayerScore({ score, min? }): Score`** | Reads a per-player `Score` and returns the maximum across all players as an anonymous `Score`. `min` (default 0) seeds the result before the sweep — pass a negative value if your per-player score can go below 0 (e.g. climb's `current_height` defaulting to -1000), otherwise the default 0 would mask negative actual maxes. Caller writes into a global custom_variable: `value.set(Actions.maxPlayerScore({ score: diamonds }))`. Lets `Actions.setEndStates` / `.end_condition` reference "any player has X" semantics correctly. | Manual `execute as @a if score @s …` loops |
34
+ | **`Actions.randomInt({ min, max }): Score`** | Uniform random integer in `[min, max]` returned as an anonymous `Score`. Wraps Minecraft's `random value` (which Sandstone has no typed wrapper for; this helper exists so user code never reaches for `raw`). | `raw("execute store result score … run random value …")` |
35
+ | **`Actions.detectBlockMissing({ at, expected }): Score`** | Returns 1 if the block at `at` is NOT `expected`, 0 if it is. Non-latching — wrap in `_.if(missing, () => value.set(1))` for the latching "once broken, stays broken" pattern (castle-ctf, castle-siege, beanstalk). | Manual `_.if(_.not(_.block(abs(...), expected)), () => value.set(1))` |
36
+ | **`Actions.knockbackWithLevitation({ target, mode, durationSeconds, amplifier })`** | Levitation (or levitation + slow-fall) effect for non-lethal displacement. | Hand-rolled `effect give … levitation` |
37
+
38
+ Worked example using `Actions.box`:
39
+
40
+ ```typescript
41
+ import { Actions } from "@kradle/challenges-sdk";
42
+
43
+ // 17×17 stone arena, 4-block walls, no roof — what boss-solo / melee-arena need.
44
+ Actions.box({
45
+ block: "minecraft:stone",
46
+ from: { x: -8, y: -60, z: -8 },
47
+ to: { x: 8, y: -56, z: 8 },
48
+ absolute: true,
49
+ hollow: true,
50
+ floor: "minecraft:stone",
51
+ });
52
+ ```
18
53
 
19
54
  ---
20
55
 
@@ -123,9 +158,49 @@ interface _BaseConfig<ROLES, VARIABLES> {
123
158
  // Optional: World spawn point for the challenge
124
159
  // Can be explicit coordinates or an entity selector (e.g., from Actions.getLocation)
125
160
  spawnPoint?: { x: number; y: number; z: number } | SelectorClass;
161
+
162
+ // Optional: Per-rule overrides for the implicit setup the SDK applies
163
+ // inside start_challenge / init_participants. See "Implicit setup" below.
164
+ defaults?: {
165
+ time?: "day" | "noon" | "night" | "midnight" | false; // default: "day"
166
+ daylightCycle?: boolean; // default: false
167
+ mobSpawning?: boolean; // default: false
168
+ naturalRegeneration?: boolean; // default: true
169
+ clearInventory?: boolean; // default: true
170
+ maxHealth?: number | false; // default: 20
171
+ } | false;
126
172
  }
127
173
  ```
128
174
 
175
+ ### Implicit setup (`defaults`)
176
+
177
+ `createChallenge` applies a small, predictable setup at the top of `start_challenge` and `init_participants` so user code doesn't have to:
178
+
179
+ - **`start_challenge`** — `time set day`, `gamerule doDaylightCycle false`, `gamerule doMobSpawning false`, `gamerule naturalRegeneration true`.
180
+ - **`init_participants`** — `clear @a[participant]`, `attribute generic.max_health base set 20`.
181
+
182
+ Override per-rule when needed:
183
+
184
+ ```typescript
185
+ // Wave-defense: keep mob spawns on
186
+ createChallenge({ defaults: { mobSpawning: true }, ... })
187
+
188
+ // PvP arena that pre-stocks inventory in start_challenge: keep what was given
189
+ createChallenge({ defaults: { clearInventory: false }, ... })
190
+
191
+ // Boss fight: 30 HP per agent
192
+ createChallenge({ defaults: { maxHealth: 30 }, ... })
193
+
194
+ // Tight combat tuning: no idle healing
195
+ createChallenge({ defaults: { naturalRegeneration: false }, ... })
196
+ ```
197
+
198
+ Set `defaults: false` to disable implicit setup entirely (you're then responsible for time, gamerules, and per-player init):
199
+
200
+ ```typescript
201
+ createChallenge({ defaults: false, ... })
202
+ ```
203
+
129
204
  **Role Validation:** When building a challenge via `kradle challenge build`, the roles in `createChallenge()` are validated against the roles defined in `config.ts`. If a role in `challenge.ts` doesn't match a role in `config.ts`, the build will fail with an error.
130
205
 
131
206
  **Spawn Point:** The `spawnPoint` option sets the world spawn point and teleports new players to it. It accepts either:
@@ -160,12 +235,14 @@ The builder uses a fluent API. Methods must be called in order:
160
235
 
161
236
  ```typescript
162
237
  createChallenge(config)
163
- .events(eventCallback) // Define lifecycle events
164
- .custom_events(customEventCallback) // Define triggered events
165
- .end_condition(endConditionCallback) // Define game end condition
166
- .win_conditions(winConditionsCallback) // Define win conditions (triggers build)
238
+ .events(eventCallback) // Lifecycle events; call Actions.setEndStates from on_tick
239
+ .custom_events(customEventCallback) // OPTIONAL score-threshold side effects (announce, sound, etc.)
240
+ .end_condition(endConditionCallback) // OPTIONAL — when the game should end (auto-includes GAME_DURATION timer)
241
+ .win_conditions(winConditionsCallback) // Per-role win conditions (triggers build)
167
242
  ```
168
243
 
244
+ **Use `Actions.setEndStates({ ... })` from `events.on_tick` for the standard "win condition X / loss Y / timeout Z" pattern.** It collapses what would otherwise be three `custom_events` entries with manual `Actions.setEndState` calls. Hand its return value (the OR of all conditions) to `.end_condition()` if you want the game to end as soon as any branch fires.
245
+
169
246
  #### `.events(callback)`
170
247
 
171
248
  ```typescript
@@ -179,7 +256,29 @@ createChallenge(config)
179
256
  })
180
257
  ```
181
258
 
182
- #### `.custom_events(callback)`
259
+ **Worked example — solo speedrun "break the gold block":**
260
+
261
+ ```typescript
262
+ let endReached: ConditionType | undefined;
263
+ challenge
264
+ .events(({ goal_broken, alive_players, game_timer }) => ({
265
+ on_tick: () => {
266
+ endReached = Actions.setEndStates({
267
+ goal_reached: goal_broken.equalTo(1),
268
+ time_up: _.or(alive_players.equalTo(0), game_timer.greaterOrEqualThan(GAME_DURATION)),
269
+ });
270
+ },
271
+ }))
272
+ .end_condition(() => endReached!);
273
+ ```
274
+
275
+ End-state names are validated against `config.endStates` at build time (same as direct `Actions.setEndState` calls).
276
+
277
+ **IMPORTANT — server context:** when called from `events.on_tick`, the conditions passed to `Actions.setEndStates` are evaluated as the server entity (not a participant). Reference **globals only** (e.g. `alive_players`, `game_timer`, your global custom_variables). For "any player has X" semantics, derive a global via a per-player updater first (see `Actions.maxPlayerScore`).
278
+
279
+ #### `.custom_events(callback)` (optional — for score-threshold side effects)
280
+
281
+ For triggered side effects keyed off score thresholds — an `announce` when score crosses N, a sound effect, a `repeatable` event. For end-states, prefer `Actions.setEndStates` from `events.on_tick`.
183
282
 
184
283
  ```typescript
185
284
  .custom_events((variables: Variables, roles: Roles) => {
@@ -208,6 +307,8 @@ createChallenge(config)
208
307
 
209
308
  #### `.end_condition(callback)`
210
309
 
310
+ Defines when the game should end. The challenge already auto-ends after `GAME_DURATION` ticks if specified — `.end_condition()` adds an extra termination criterion that's OR'd with the timer. The most common pattern is to capture the return of `Actions.setEndStates(...)` from `on_tick` and feed it back here, so the game ends as soon as any end-state has been applied.
311
+
211
312
  ```typescript
212
313
  .end_condition((variables: Variables, roles: Roles) => {
213
314
  return Condition; // Returns a Sandstone condition
@@ -284,6 +385,46 @@ Common objective types:
284
385
 
285
386
  You can find objectives definition on the [Scoreboard](https://minecraft.fandom.com/wiki/Scoreboard) wiki pagem as well as the [Statistics](https://minecraft.fandom.com/wiki/Statistics) page for more in-depth explanation.
286
387
 
388
+ > **⚠️ `minecraft.picked_up` only fires on item-entity pickup, NOT on `Actions.give`.**
389
+ >
390
+ > If you award items via `Actions.give({ item, target, count })` and your `score` uses `objective_type: "minecraft.picked_up:minecraft.<item>"`, the score will stay at `0` because `/give` adds straight to the player's inventory and never spawns the item entity that triggers the statistic.
391
+ >
392
+ > Only one of these patterns will register on `picked_up`:
393
+ > 1. Drop the item with `summon item ... ItemTags ...` so the player walks over it (slow + visible).
394
+ > 2. Track the score yourself in a `dummy` objective and increment it at every payout site.
395
+ >
396
+ > Pattern 2 is almost always what you want for "emeralds awarded so far":
397
+ > ```typescript
398
+ > custom_variables: {
399
+ > score: {
400
+ > type: "individual",
401
+ > objective_type: "dummy",
402
+ > default: 0,
403
+ > updater: (value, { main_score }) => main_score.set(value),
404
+ > },
405
+ > ticks_in_a: {
406
+ > type: "individual",
407
+ > objective_type: "dummy",
408
+ > default: 0,
409
+ > // The second argument to `updater` exposes every other custom_variable —
410
+ > // including `score` — so you can bump it yourself wherever Actions.give fires.
411
+ > updater: (value, { score }) => {
412
+ > forEveryPlayer(() => {
413
+ > _.if(<on_platform>, () => {
414
+ > value.add(1);
415
+ > _.if(value.modulo(20).equalTo(0), () => {
416
+ > Actions.give({ target: "self", item: "minecraft:emerald", count: 1 });
417
+ > score.add(1);
418
+ > });
419
+ > });
420
+ > });
421
+ > },
422
+ > },
423
+ > }
424
+ > ```
425
+ >
426
+ > The same applies to "deduction" via `clear @s emerald N` — `picked_up` does NOT decrement when an item leaves the inventory. If you need a *current-balance* score (e.g. "ended with > 50 emeralds"), pair every `give`/`clear` with explicit `score.add(N)` / `score.remove(N)`.
427
+
287
428
  #### Individual Dummy
288
429
 
289
430
  Computed per-player values:
@@ -349,6 +490,8 @@ score.lowerThan(number | Score);
349
490
  score.lowerOrEqualThan(number | Score);
350
491
  ```
351
492
 
493
+ A `Score` is itself a Condition that compiles to `unless score @s OBJ matches 0` — i.e. **truthy = non-zero**. So for 0/1 latches, `_.if(flag, ...)` is the short form of `_.if(flag.equalTo(1), ...)`. **But not** for counters that can hit ≥2 — `_.if(alive_players)` matches "≥1 alive", `_.if(alive_players.equalTo(1))` matches "exactly one alive" (the last-player-standing semantic). Use the truthy form only when you're sure the score is bounded to 0/1.
494
+
352
495
  ---
353
496
 
354
497
  ## Events
@@ -581,6 +724,15 @@ Actions.tellraw({
581
724
  target: "self",
582
725
  message: { text: "Critical hit!", color: "red", bold: true }
583
726
  });
727
+
728
+ // Score values embed directly into the message — Sandstone serializes them
729
+ // as `{ score: { name, objective } }` and Minecraft renders each recipient's
730
+ // own value. So a single tellraw to "all" shows each player THEIR number,
731
+ // no `forEveryPlayer` + `_.if(player_number.equalTo(N))` branching needed:
732
+ Actions.tellraw({
733
+ target: "all",
734
+ message: [{ text: "Your player number is ", color: "yellow" }, player_number],
735
+ });
584
736
  ```
585
737
 
586
738
  ### Items & Inventory
@@ -824,6 +976,110 @@ createChallenge({
824
976
 
825
977
  **Important:** When building via `kradle challenge build`, locations are validated against the `locations` defined in `config.ts`. If a location name doesn't exist in the config, the build will fail with an error listing valid locations.
826
978
 
979
+ #### `Actions.box(params)`
980
+
981
+ Build a box (4 walls + optional floor/roof) in a single call. Replaces the typical 5+ `Actions.fill` calls per arena.
982
+
983
+ ```typescript
984
+ Actions.box({
985
+ block: BLOCKS; // Wall block
986
+ from: { x, y, z }; // First corner (inclusive)
987
+ to: { x, y, z }; // Opposite corner (inclusive)
988
+ absolute: boolean;
989
+ hollow?: boolean; // Default true: only walls placed
990
+ floor?: BLOCKS; // Optional override block for floor
991
+ roof?: BLOCKS; // Optional roof
992
+ clearInside?: BLOCKS | boolean; // true = air; supply a BLOCKS to fill interior
993
+ });
994
+
995
+ // Example - 13×13×5 stone arena with stone-bricks floor, no roof, interior cleared
996
+ Actions.box({
997
+ block: "minecraft:stone",
998
+ from: { x: -6, y: -60, z: -6 },
999
+ to: { x: 6, y: -55, z: 6 },
1000
+ absolute: true,
1001
+ hollow: true,
1002
+ floor: "minecraft:stone_bricks",
1003
+ clearInside: true,
1004
+ });
1005
+ ```
1006
+
1007
+ #### `Actions.countBlocksInRegion(params)`
1008
+
1009
+ Count instances of a block within an axis-aligned region. Returns a `Score`. Useful for detecting agent-built structures without pinning detection to a single coordinate (which breaks the moment the agent moves before building).
1010
+
1011
+ ```typescript
1012
+ Actions.countBlocksInRegion({
1013
+ block: BLOCKS; // Block ID to count (e.g. "minecraft:cobblestone")
1014
+ from: { x, y, z }; // First corner (inclusive)
1015
+ to: { x, y, z }; // Opposite corner (inclusive)
1016
+ absolute: boolean;
1017
+ }): Score;
1018
+
1019
+ // Detect any 5+ cobblestone block tower built within a 12×6×12 zone:
1020
+ const count = Actions.countBlocksInRegion({
1021
+ block: "minecraft:cobblestone",
1022
+ from: { x: -6, y: -60, z: -6 },
1023
+ to: { x: 6, y: -55, z: 6 },
1024
+ absolute: true,
1025
+ });
1026
+ _.if(count.greaterOrEqualThan(5), () => Actions.announce({ message: "Tower built!" }));
1027
+ ```
1028
+
1029
+ The region is hard-capped at 4096 cells (~64×8×8) — emits one `execute if block` check per cell at codegen time, so larger regions would bloat the generated datapack. If you need to scan a bigger area, do it in slices.
1030
+
1031
+ #### `Actions.countBlocksRelativeTo(params)`
1032
+
1033
+ Same as `countBlocksInRegion`, but the bounds are *relative to a target's current position*. Finds structures the agent built nearby regardless of where they wandered after spawning. Returns a `Score`.
1034
+
1035
+ ```typescript
1036
+ Actions.countBlocksRelativeTo({
1037
+ target: TargetNames; // "self", "all", or any selector
1038
+ block: BLOCKS;
1039
+ dxRange: [number, number]; // x offsets relative to target (e.g. [-8, 8])
1040
+ dyRange: [number, number]; // y offsets (e.g. [0, 5] for "this block + 5 above")
1041
+ dzRange: [number, number];
1042
+ }): Score;
1043
+
1044
+ // Detect a 5-tall pillar within 8 blocks of the agent (in either direction):
1045
+ pillar_built: {
1046
+ type: "individual",
1047
+ default: 0,
1048
+ updater: (value) => {
1049
+ value.set(0);
1050
+ const count = Actions.countBlocksRelativeTo({
1051
+ target: "self",
1052
+ block: "minecraft:cobblestone",
1053
+ dxRange: [-8, 8],
1054
+ dyRange: [0, 5],
1055
+ dzRange: [-8, 8],
1056
+ });
1057
+ _.if(count.greaterOrEqualThan(5), () => value.set(1));
1058
+ },
1059
+ }
1060
+ ```
1061
+
1062
+ Use this instead of `countBlocksInRegion` when the build site moves with the agent. Same 4096-cell cap.
1063
+
1064
+ #### `Actions.knockbackWithLevitation(params)`
1065
+
1066
+ Apply a non-lethal knockback / displacement via Minecraft's `effect` command. Default is a brief levitation (lifts straight up without dealing damage). Pass `mode: "slowfall"` to combine with slow-falling for a soft landing.
1067
+
1068
+ ```typescript
1069
+ Actions.knockbackWithLevitation({
1070
+ target: TargetNames;
1071
+ durationSeconds?: number; // Default 1
1072
+ amplifier?: number; // Default 5
1073
+ mode?: "levitation" | "slowfall";
1074
+ });
1075
+
1076
+ // Periodic knockback in a King-of-the-Hill challenge:
1077
+ Actions.knockbackWithLevitation({ target: "all", durationSeconds: 2, amplifier: 6 });
1078
+
1079
+ // Knock + slow-fall for safe landing:
1080
+ Actions.knockbackWithLevitation({ target: "self", mode: "slowfall", durationSeconds: 3 });
1081
+ ```
1082
+
827
1083
  ### World
828
1084
 
829
1085
  #### `Actions.setBlock(params)`
@@ -958,6 +1214,127 @@ Actions.decrement({
958
1214
  Actions.decrement({ variable: variables.counter });
959
1215
  ```
960
1216
 
1217
+ #### `Actions.standingOnBlock(params)` → `Selector`
1218
+
1219
+ Returns a Sandstone selector that matches `@s` ONLY when standing on the block at `(x, y, z)`. Use this anywhere you need a "is the player on this pad / tile / pressure plate?" check — the bare `Selector("@s", { x, y, z })` form does NOT constrain position (Minecraft treats x/y/z as the selector ORIGIN for distance math, not as a position filter), so the condition silently passes for everyone. This helper bakes in `dx: 0, dy: 1, dz: 0` so the selector matches the 1×1×2 column above the floor block.
1220
+
1221
+ ```typescript
1222
+ Actions.standingOnBlock({
1223
+ x: number; // X of the floor block
1224
+ y: number; // Y of the floor block (player's feet are at y+1)
1225
+ z: number; // Z of the floor block
1226
+ role?: string; // Optional role tag the player must also have
1227
+ });
1228
+ ```
1229
+
1230
+ ```typescript
1231
+ // Trigger alice_shared when Alice steps on her pad block.
1232
+ alice_shared: {
1233
+ type: "global",
1234
+ default: 0,
1235
+ updater: (value) => {
1236
+ _.if(value.equalTo(0), () => {
1237
+ forEveryPlayer(() => {
1238
+ _.if(
1239
+ Actions.standingOnBlock({ x: ALICE_PAD.x, y: ALICE_PAD.y, z: ALICE_PAD.z, role: "alice" }),
1240
+ () => { value.set(1); },
1241
+ );
1242
+ });
1243
+ });
1244
+ },
1245
+ },
1246
+ ```
1247
+
1248
+ ### Voting
1249
+
1250
+ Challenges can offer named votes, declared in `config.ts` as `challengeConfig.votingOptions` (e.g. `{ selectroom: ["red", "green", "blue"] }`). Agents cast a vote with `skills.voteForOption(voteId, option)`; the arena validates it (rejecting unknown options and second votes) and records the choice as the player tags `kradle.voteOptions.<voteId>` (voted-at-all) and `kradle.voteOptions.<voteId>.<option>` (the choice). The actions below read and drive those tags, so you can build a full "discuss → vote → act on the result" phase (see Pattern 13). Vote ids and options must be tag-safe and dot-free (`[A-Za-z0-9_-]`). Like `getLocation`, the vote actions validate at build time against the declared `votingOptions`: an undeclared vote id (or an option not in that vote) fails `kradle challenge build`, catching typos early.
1251
+
1252
+ #### `Actions.votedFor(voteId, option?)` → `Selector`
1253
+
1254
+ Per-player condition matching `@s` when the player has voted in `voteId` (any option), or — when `option` is given — only when they voted for that specific option. Use it in `_.if` (inside `forEveryPlayer`), `setEndStates`, or win conditions. It matches `@s`, so **do not** pass it to `execute.as(...)` from a tick/load context — `@s` resolves to no one there and the command runs for nobody (the build's context-safety check flags this).
1255
+
1256
+ ```typescript
1257
+ Actions.votedFor("selectroom"); // voted in selectroom at all
1258
+ Actions.votedFor("selectroom", "red"); // voted specifically for red
1259
+ ```
1260
+
1261
+ ```typescript
1262
+ // Move everyone who picked the red room — votedFor is a per-player condition,
1263
+ // so check it inside forEveryPlayer (which makes @s each participant).
1264
+ forEveryPlayer(() => {
1265
+ _.if(Actions.votedFor("selectroom", "red"), () => {
1266
+ Actions.teleport({ target: "self", x: 10, y: -60, z: 10, absolute: true });
1267
+ });
1268
+ });
1269
+ ```
1270
+
1271
+ #### `Actions.promptVote(params)`
1272
+
1273
+ Announce a vote to all participants over the `***KRADLE***` channel agents read, naming the vote and its options.
1274
+
1275
+ ```typescript
1276
+ Actions.promptVote({
1277
+ voteId: string; // matches a key in config votingOptions
1278
+ prompt: string; // human-readable instruction
1279
+ options: string[]; // the valid options to surface
1280
+ });
1281
+
1282
+ // Example:
1283
+ Actions.promptVote({ voteId: "selectroom", prompt: "Pick a room!", options: ["red", "green", "blue"] });
1284
+ ```
1285
+
1286
+ #### `Actions.allVotesIn(voteId)` → `ConditionType`
1287
+
1288
+ True once every participant has voted in `voteId` (no participant is still missing the `kradle.voteOptions.<voteId>` tag). Combine with a timer to end a vote phase as soon as everyone is in.
1289
+
1290
+ ```typescript
1291
+ _.if(_.or(Actions.allVotesIn("selectroom"), game_timer.greaterOrEqualThan(600)), () => {
1292
+ // resolve the vote (600 ticks = 30s)
1293
+ });
1294
+ ```
1295
+
1296
+ #### `Actions.assignDefaultVotes(params)`
1297
+
1298
+ Record `default` for every participant who has not voted in `voteId` yet — call it on timeout so non-voters are still moved by `votedFor`. Writes the same tags the arena would.
1299
+
1300
+ ```typescript
1301
+ Actions.assignDefaultVotes({
1302
+ voteId: string; // the vote
1303
+ default: string; // option assigned to non-voters
1304
+ });
1305
+
1306
+ // Example:
1307
+ Actions.assignDefaultVotes({ voteId: "selectroom", default: "red" });
1308
+ ```
1309
+
1310
+ **Full vote phase** — prompt once, resolve when all are in or 30s elapse, default non-voters, then move each player by their choice. Gate the resolve block behind a global `resolved` flag so it fires once:
1311
+
1312
+ ```typescript
1313
+ // config.ts: challengeConfig.votingOptions = { selectroom: ["red", "green", "blue"] }
1314
+ const ROOMS = { red: { x: 10, y: -60, z: 10 }, green: { x: 20, y: -60, z: 10 }, blue: { x: 30, y: -60, z: 10 } };
1315
+
1316
+ challenge.events(({ game_timer, resolved }) => ({
1317
+ start_challenge: () => {
1318
+ Actions.promptVote({ voteId: "selectroom", prompt: "Pick a room!", options: Object.keys(ROOMS) });
1319
+ },
1320
+ on_tick: () => {
1321
+ _.if(resolved.equalTo(0), () => {
1322
+ _.if(_.or(Actions.allVotesIn("selectroom"), game_timer.greaterOrEqualThan(600)), () => {
1323
+ resolved.set(1);
1324
+ Actions.assignDefaultVotes({ voteId: "selectroom", default: "red" });
1325
+ forEveryPlayer(() => {
1326
+ for (const [option, pos] of Object.entries(ROOMS)) {
1327
+ _.if(Actions.votedFor("selectroom", option), () => {
1328
+ Actions.teleport({ target: "self", x: pos.x, y: pos.y, z: pos.z, absolute: true });
1329
+ });
1330
+ }
1331
+ });
1332
+ });
1333
+ });
1334
+ },
1335
+ }));
1336
+ ```
1337
+
961
1338
  ### Player Attributes
962
1339
 
963
1340
  #### `Actions.setAttribute(params)`
@@ -1041,6 +1418,36 @@ Actions.setEndState({ endState: "defeat" });
1041
1418
  Actions.setEndState({ endState: "unknown" }); // Error: Invalid end state "unknown"
1042
1419
  ```
1043
1420
 
1421
+ #### `Actions.setEndStates(map): ConditionType`
1422
+
1423
+ For each `(name, condition)` pair, emit `_.if(condition, () => setEndState(name))` at the current codegen location. Where (and how often) those checks run is up to the caller — `events.on_tick` for a per-tick check, `custom_events.actions` for a single fire, etc.
1424
+
1425
+ Returns a `ConditionType` that's true when at least one of the input conditions is true (i.e. an end-state was applied). Hand it to `.end_condition()` if you want the game to end as soon as any end-state has been set; ignore it if the game's end is gated on something else (timer, separate condition).
1426
+
1427
+ ```typescript
1428
+ Actions.setEndStates({
1429
+ [endStateName: string]: ConditionType,
1430
+ }): ConditionType;
1431
+ ```
1432
+
1433
+ ```typescript
1434
+ let endReached: ConditionType | undefined;
1435
+ challenge
1436
+ .events(({ goal_broken, alive_players, game_timer }) => ({
1437
+ on_tick: () => {
1438
+ endReached = Actions.setEndStates({
1439
+ goal_reached: goal_broken.equalTo(1),
1440
+ time_up: _.or(alive_players.equalTo(0), game_timer.greaterOrEqualThan(GAME_DURATION)),
1441
+ });
1442
+ },
1443
+ }))
1444
+ .end_condition(() => endReached!);
1445
+ ```
1446
+
1447
+ End-state names are validated against `config.endStates` at build time, same as direct `Actions.setEndState` calls.
1448
+
1449
+ **Note: server context** — when called from `events.on_tick` (the most common spot), conditions are evaluated as the server entity, where per-player score reads silently fail. Reference globals only; for "any player has X" semantics use `Actions.maxPlayerScore` to materialize a global first.
1450
+
1044
1451
  ---
1045
1452
 
1046
1453
  ## Utilities
@@ -1059,6 +1466,24 @@ forEveryPlayer(() => {
1059
1466
  });
1060
1467
  ```
1061
1468
 
1469
+ ### `forEachOtherPlayer(callback)`
1470
+
1471
+ Inside a `forEveryPlayer` (or any "@s is an outer participant") context, run `callback` for every OTHER participant. Inside the callback `@s` is the inner participant; the outer participant is no longer `@s` but is tagged `kradle_pair_self` for the duration of the inner loop so you can refer to them.
1472
+
1473
+ Visits ordered pairs (each unordered pair appears twice, once with each direction). For each-pair-once semantics, gate the callback on `other.player_number > self.player_number`.
1474
+
1475
+ ```typescript
1476
+ import { forEveryPlayer, forEachOtherPlayer } from "@kradle/challenges-sdk";
1477
+
1478
+ forEveryPlayer(() => {
1479
+ forEachOtherPlayer(() => {
1480
+ // @s = the inner player; @a[tag=kradle_pair_self] = the outer player.
1481
+ // Useful for matchup-style logic: prisoner's dilemma payoffs, alliance
1482
+ // detection, "did I bump into another player" events, etc.
1483
+ });
1484
+ });
1485
+ ```
1486
+
1062
1487
  **Important Notes:**
1063
1488
  - Individual variables automatically reference the current player (`@s`) within the loop
1064
1489
  - Global variables remain global and are the same across all iterations
@@ -1259,12 +1684,8 @@ createChallenge({
1259
1684
  max_height_global: {
1260
1685
  type: "global",
1261
1686
  updater: (value, { max_height }) => {
1262
- value.set(0);
1263
- forEveryPlayer(() => {
1264
- _.if(max_height.greaterThan(value), () => {
1265
- value.set(max_height);
1266
- });
1267
- });
1687
+ // Pass `min: -1000` because climber Y can go below 0 (lava drops, void).
1688
+ value.set(Actions.maxPlayerScore({ score: max_height, min: -1000 }));
1268
1689
  },
1269
1690
  },
1270
1691
  is_winner: {
@@ -1384,12 +1805,7 @@ createChallenge({
1384
1805
  pig_killed_max: {
1385
1806
  type: "global",
1386
1807
  updater: (value, { pigs_killed }) => {
1387
- value.set(0);
1388
- forEveryPlayer(() => {
1389
- _.if(pigs_killed.greaterThan(value), () => {
1390
- value.set(pigs_killed);
1391
- });
1392
- });
1808
+ value.set(Actions.maxPlayerScore({ score: pigs_killed }));
1393
1809
  },
1394
1810
  },
1395
1811
  },
@@ -1489,6 +1905,22 @@ createChallenge({
1489
1905
 
1490
1906
  ## Common Patterns
1491
1907
 
1908
+ | # | Pattern | Use when | Canonical challenge |
1909
+ |---|---------|----------|---------------------|
1910
+ | 1 | Sync variable to main_score | Score should display on the watcher leaderboard | sprint, patience |
1911
+ | 2 | Find global maximum | "Highest score wins" comparisons | crafters-race, splat-rush |
1912
+ | 3 | Winner detection (has max + alive) | Latched is_winner that requires surviving | prisoners-dilemma, king-of-the-hill |
1913
+ | 4 | Track player position | Per-player coordinate snapshot for distance/region checks | sprint, lighthouse |
1914
+ | 5 | Check inventory for item | "Has the agent crafted X yet?" | crafters-race, biome-bazaar |
1915
+ | 6 | Check block below player | "Is the agent on a specific tile?" — use `Actions.standingOnBlock` | sacrifice, reputation |
1916
+ | 7 | Count entities | Wave / mob accounting | wave-survivor, zombie-survival |
1917
+ | 8 | Simple end condition | Single-condition timer or score gate | sprint |
1918
+ | 9 | Multi-condition end | OR of timer / score / event gates | crafters-race |
1919
+ | 10 | Opposing-team win conditions | Capture-the-flag style 0/1 flag state | castle-ctf, capture-the-flag |
1920
+ | 11 | Per-participant win attribution inside a single role | Same role, but only some win (saboteur vs miners) | saboteur |
1921
+ | 12 | Sequential turn-taking | "One player acts at a time" — current_voter gate | cascade, adaptive |
1922
+ | 13 | Two-phase game (sequential broadcast → simultaneous decision) | Vote after public discussion | common-knowledge |
1923
+
1492
1924
  ### Pattern 1: Sync Variable to Main Score
1493
1925
 
1494
1926
  ```typescript
@@ -1507,16 +1939,13 @@ my_variable: {
1507
1939
  max_global: {
1508
1940
  type: "global",
1509
1941
  updater: (value, { individual_score }) => {
1510
- value.set(0);
1511
- forEveryPlayer(() => {
1512
- _.if(individual_score.greaterThan(value), () => {
1513
- value.set(individual_score);
1514
- });
1515
- });
1942
+ value.set(Actions.maxPlayerScore({ score: individual_score }));
1516
1943
  },
1517
1944
  }
1518
1945
  ```
1519
1946
 
1947
+ Pass `min: -1000` (or any sufficiently-negative bound) when the per-player score can go below 0 — the default `min: 0` would otherwise mask negative actual maxes.
1948
+
1520
1949
  ### Pattern 3: Winner Detection (Has Max Score + Alive)
1521
1950
 
1522
1951
  ```typescript
@@ -1587,14 +2016,13 @@ on_gold_block: {
1587
2016
  zombie_count: {
1588
2017
  type: "global",
1589
2018
  updater: (value) => {
1590
- value.set(0);
1591
- execute.as(Selector("@e", { type: "zombie" })).run(() => {
1592
- value.add(1);
1593
- });
2019
+ value.set(Actions.countEntities({ entity: "minecraft:zombie" }));
1594
2020
  },
1595
2021
  }
1596
2022
  ```
1597
2023
 
2024
+ Combine filters with `tag` and/or `volume` for "zombies in the arena" or "tagged hunt targets only". At least one filter must be provided (counting "every entity in the world" throws at build time).
2025
+
1598
2026
  ### Pattern 8: Simple End Condition
1599
2027
 
1600
2028
  ```typescript
@@ -1621,13 +2049,228 @@ zombie_count: {
1621
2049
  }))
1622
2050
  ```
1623
2051
 
2052
+ ### Pattern 11: Per-participant Win Attribution Inside a Single Role
2053
+
2054
+ For asymmetric games where one role hides another (e.g. social deduction: 1 secret saboteur + 3 miners share the same `player` role), `.win_conditions` is keyed by role but its body runs `as @a[tag=<role>]` — so `@s` is each participant. Filter further inside the condition with `Selector("@s", { tag })`:
2055
+
2056
+ ```typescript
2057
+ .win_conditions(({ saboteur_alive, diamonds_mined }, { player }) => ({
2058
+ [player]: _.or(
2059
+ _.and(
2060
+ Selector("@s", { tag: "saboteur" } as any),
2061
+ saboteur_alive.equalTo(1),
2062
+ diamonds_mined.lowerThan(4),
2063
+ ),
2064
+ _.and(
2065
+ Selector("@s", { tag: "!saboteur" } as any),
2066
+ _.or(diamonds_mined.greaterOrEqualThan(4), saboteur_alive.equalTo(0)),
2067
+ ),
2068
+ ),
2069
+ }))
2070
+ ```
2071
+
2072
+ This is how saboteur ends up correctly attributing the win to the saboteur participant when miners run out of time, and to all miners when they catch the impostor — without needing a separate `saboteur` role. The `as any` cast on the negated tag is a Sandstone selector-typing limitation; `tag: "!saboteur"` is a valid Minecraft selector token but the type narrowing doesn't model it.
2073
+
2074
+ ### Pattern 12: Sequential turn-taking (one player acts at a time)
2075
+
2076
+ For games where only one participant should act per tick — like cascade's information-cascade voting where each voter must wait their turn — gate the action updater on `player_number == current_voter`. The SDK's built-in `player_number` is per-player (1, 2, 3, … assigned in init_participants); maintain a global `current_voter` that you bump when a player completes their turn:
2077
+
2078
+ ```typescript
2079
+ custom_variables: {
2080
+ // ... your other variables ...
2081
+ current_voter: { type: "global", default: 1 },
2082
+ has_voted: { type: "individual", objective_type: "dummy", default: 0 },
2083
+ vote: {
2084
+ type: "individual",
2085
+ objective_type: "dummy",
2086
+ default: -1,
2087
+ updater: (value, { current_voter, has_voted, player_number }) => {
2088
+ forEveryPlayer(() => {
2089
+ _.if(
2090
+ _.and(has_voted.equalTo(0), player_number.equalTo(current_voter)),
2091
+ () => {
2092
+ _.if(Actions.standingOnBlock(RED_PAD), () => {
2093
+ value.set(0);
2094
+ has_voted.set(1);
2095
+ current_voter.add(1);
2096
+ });
2097
+ _.if(Actions.standingOnBlock(BLUE_PAD), () => {
2098
+ value.set(1);
2099
+ has_voted.set(1);
2100
+ current_voter.add(1);
2101
+ });
2102
+ },
2103
+ );
2104
+ });
2105
+ },
2106
+ },
2107
+ },
2108
+ // Announce each turn boundary via custom_events keyed on current_voter:
2109
+ .custom_events(({ current_voter }) => [
2110
+ { score: current_voter, target: 2, mode: "fire_once",
2111
+ actions: () => Actions.announce({ message: [{ text: "Voter 2's turn.", color: "yellow" }] }) },
2112
+ { score: current_voter, target: 3, mode: "fire_once",
2113
+ actions: () => Actions.announce({ message: [{ text: "Voter 3's turn.", color: "yellow" }] }) },
2114
+ // ... one per voter beyond the first ...
2115
+ ])
2116
+ ```
2117
+
2118
+ The compiled mcfunction reads `execute if score @s has_voted matches 0 if score @s player_number = current_voter kradle.board run …` — the player_number-vs-current_voter equality is what enforces serialization. Used by cascade and adaptive.
2119
+
2120
+ ### Pattern 13: Two-phase game (sequential broadcast, then simultaneous decision)
2121
+
2122
+ Common-knowledge formation games and similar designs need a sequential phase 1 (one act per turn) followed by a simultaneous phase 2 (all act independently in private). Phase 1 reuses Pattern 12; phase 2 gates on a global "phase 1 complete" condition AND uses **per-player rooms** so each player's decision stays private:
2123
+
2124
+ ```typescript
2125
+ custom_variables: {
2126
+ // ... phase 1: same as Pattern 12 ...
2127
+ broadcasts_done: {
2128
+ type: "global",
2129
+ default: 0,
2130
+ updater: (value, { has_broadcast }) => {
2131
+ value.set(0);
2132
+ forEveryPlayer(() => { value.add(has_broadcast); });
2133
+ },
2134
+ },
2135
+ has_voted_final: { type: "individual", objective_type: "dummy", default: 0 },
2136
+ final_vote: {
2137
+ type: "individual",
2138
+ objective_type: "dummy",
2139
+ default: -1,
2140
+ updater: (value, { broadcasts_done, has_voted_final, player_number }) => {
2141
+ // Per-player room layout — keeps each player's vote private until all cast.
2142
+ const FINAL_PADS = [
2143
+ { red: FINAL_RED_P1, blue: FINAL_BLUE_P1 },
2144
+ { red: FINAL_RED_P2, blue: FINAL_BLUE_P2 },
2145
+ { red: FINAL_RED_P3, blue: FINAL_BLUE_P3 },
2146
+ ];
2147
+ forEveryPlayer(() => {
2148
+ _.if(
2149
+ _.and(broadcasts_done.equalTo(3), has_voted_final.equalTo(0)),
2150
+ () => {
2151
+ FINAL_PADS.forEach((pads, idx) => {
2152
+ _.if(player_number.equalTo(idx + 1), () => {
2153
+ _.if(Actions.standingOnBlock(pads.red), () => {
2154
+ value.set(0);
2155
+ has_voted_final.set(1);
2156
+ });
2157
+ _.if(Actions.standingOnBlock(pads.blue), () => {
2158
+ value.set(1);
2159
+ has_voted_final.set(1);
2160
+ });
2161
+ });
2162
+ });
2163
+ },
2164
+ );
2165
+ });
2166
+ },
2167
+ },
2168
+ },
2169
+ // In init_participants, tell each player their player_number — without it, agents can't tell
2170
+ // which "Voter 2's turn" announcement is theirs and can't navigate to the right phase-2 room.
2171
+ // One tellraw to "all" with player_number embedded does it (each recipient sees their own value):
2172
+ // Actions.tellraw({ target: "all", message: ["Your player number is ", player_number] });
2173
+ ```
2174
+
2175
+ **Collective-win tip:** for "all-or-nothing" reward (everyone wins iff every vote is correct AND unanimous), add a global `correct_votes` updater that sums `(has_voted_final == 1 AND final_vote == truth)` per player; then `.win_conditions` can gate `[role]: correct_votes.equalTo(N)`. Used by common-knowledge.
2176
+
2177
+ **Skill-based voting (alternative to pads):** when the decision is a choice among named options rather than a physical location, skip the per-player rooms entirely. Declare `votingOptions` in `config.ts`, have agents call `skills.voteForOption(voteId, option)` (votes are private — each agent whispers the watcher), and read the result with `Actions.votedFor(voteId, option?)`. Use `Actions.promptVote` to announce the vote, `Actions.allVotesIn(voteId)` (paired with a `game_timer` deadline) to detect when everyone's cast, and `Actions.assignDefaultVotes` to default non-voters on timeout — see the **Voting** section under Actions. The pad-based approach above is still the right choice when the vote *is* a spatial commitment (which room you walk into) or you want votes visible via player position.
2178
+
2179
+ ---
2180
+
2181
+ ## Common Gotchas
2182
+
2183
+ These are real bugs that bit AI authors building the existing challenge corpus. Read before authoring; reach for the SDK helper called out where one exists.
2184
+
2185
+ ### Relative coords in global custom_variable updaters resolve at world origin (0, 0, 0)
2186
+
2187
+ `_.block(rel(...))` and any sandstone command using `rel(...)` inside a **global** variable updater evaluates relative to the server entity at `(0, 0, 0)`, not at any player position. A "is the player standing on the trigger pad" check that uses `rel(0, -1, 0)` will silently always look at one block below world origin.
2188
+
2189
+ **Fix:** wrap the updater body in `forEveryPlayer(() => { ... })` so the relative coords resolve at `@s` (each player's position):
2190
+
2191
+ ```ts
2192
+ trigger_pressed: {
2193
+ type: "global",
2194
+ default: 0,
2195
+ updater: (value) => {
2196
+ forEveryPlayer(() => {
2197
+ _.if(_.block(rel(0, -1, 0), "minecraft:gold_block"), () => {
2198
+ value.set(1);
2199
+ });
2200
+ });
2201
+ },
2202
+ }
2203
+ ```
2204
+
2205
+ This bit sequence-pad's wool-pad detection when the updater ran at world origin instead of per-player.
2206
+
2207
+ ### Sandstone NBT serialization: `true` → `{}`, `false` → `0b`
2208
+
2209
+ Both are invalid NBT tokens. Any time you'd reach for sandstone's typed `summon(entity, abs(...), { CustomNameVisible: true, … })`, the resulting datapack command will be malformed and the summon will fail silently in-game.
2210
+
2211
+ **Fix:** use **`Actions.summonEntityWithStats`** for boss-style configured mobs (HP / attack damage / movement speed / tags). It builds the NBT command string directly via sandstone's `raw()` and dodges the gotcha. For un-configured spawns, plain `Actions.summonMultiple` works fine.
2212
+
2213
+ ### `Actions.tagEntity?.(...)` / `Actions.tagPlayer` don't exist
2214
+
2215
+ These look plausible but aren't on the Actions object. Optional-chaining (`Actions.tagEntity?.(...)`) silently no-ops, which means your "mark hunt targets" call does nothing and your tagged-entity selectors find zero matches.
2216
+
2217
+ **Fix:** import `tag` from sandstone directly: `tag("@e[type=chicken,distance=..1]").add("hunt_target")`. Or design around tags entirely — chicken-chase ended up counting all chickens since flat-world has no naturally-spawned ones to confuse the count.
2218
+
2219
+ ### `score.lessThan` doesn't exist; use `lowerThan`
2220
+
2221
+ Sandstone's `Score` API uses `lowerThan` and `greaterThan`. `lessThan` and `moreThan` look like reasonable guesses but TypeScript won't catch them at build time without `tsc --noEmit` (the kradle CLI build doesn't run strict tsc).
2222
+
2223
+ ### `scheduled_events` / `setInterval`-style helpers don't exist
2224
+
2225
+ For "spawn a wave every N seconds", use `.custom_events` with one `fire_once` score event per wave keyed off `game_timer` at the right tick value. wave-survival uses 3 explicit entries; golem-ally maps an array of `WAVE_TICKS` to `custom_events` entries with `.map(...)`.
2226
+
2227
+ ### `gamerule "pvp"` is not a real gamerule in 1.20.4
2228
+
2229
+ PvP is on by default in flat-world. Spyglass validation rejects this rule. Just don't set it.
2230
+
2231
+ ### End-state names are validated at build time against `config.ts → endStates`
2232
+
2233
+ `Actions.setEndState({ endState: "x" })` and `Actions.setEndStates({ x: ... })` both check each name against the `endStates` map declared in your `config.ts`. A typo throws at build time, not at runtime — useful to lean on, but means you must keep `config.ts` in sync when adding/renaming an end state.
2234
+
2235
+ ### `events.on_tick` and `.custom_events` run as the server entity, not as participants
2236
+
2237
+ Same context issue as the global-updater gotcha above. End-state conditions that reference a per-player score directly will silently fail (server has no entry on a per-player objective; `if score @s X matches Y` short-circuits the whole compound). For "any player has X" semantics in the conditions you pass to `Actions.setEndStates` from `on_tick`, derive a global via a per-player updater first (e.g. `Actions.maxPlayerScore({ score })`), then reference that global in the condition.
2238
+
2239
+ ### `Selector("@s", { x, y, z })` does NOT constrain position
2240
+
2241
+ This is the trap that bit `sacrifice` — the alice/bob "stepped on the pad" check fired the moment the player existed because the selector didn't actually require position. In Minecraft selectors, `x/y/z` without `dx/dy/dz` defines the *origin* used for distance/volume math, not a position filter. Without a volume the entity check passes for everyone.
2242
+
2243
+ **Fix:** use `Actions.standingOnBlock({ x, y, z, role? })` (recommended) — it bakes in the right `dx: 0, dy: 1, dz: 0` so the selector matches a 1×1×2 column above the floor block. If you must hand-roll the selector, include `dx`/`dy`/`dz` explicitly:
2244
+
2245
+ ```typescript
2246
+ // ❌ Silently passes for every entity:
2247
+ Selector("@s", { tag: "alice", x: PAD.x, y: PAD.y + 1, z: PAD.z })
2248
+
2249
+ // ✅ Matches only when feet are at (PAD.x, PAD.y+1, PAD.z):
2250
+ Selector("@s", { tag: "alice", x: PAD.x, y: PAD.y + 1, z: PAD.z, dx: 0, dy: 1, dz: 0 })
2251
+ // or — preferred:
2252
+ Actions.standingOnBlock({ x: PAD.x, y: PAD.y, z: PAD.z, role: "alice" })
2253
+ ```
2254
+
2255
+ ### `minecraft.picked_up:<item>` does NOT fire on `Actions.give` (also: does NOT decrement on `clear`)
2256
+
2257
+ Already covered in the "Common objective types" section earlier — short version: `objective_type: "minecraft.picked_up:..."` only counts ITEM-ENTITY pickups. `/give` writes straight to inventory and bypasses the statistic. Same for `clear` — the counter doesn't go down. Use `objective_type: "dummy"` and bump the score yourself next to every `Actions.give` / `clear` call (the `updater` second arg exposes other custom_variables, so you can grab `{ score }` and call `score.add(N)` / `score.remove(N)` inline).
2258
+
2259
+ ### Don't reach for `raw("scoreboard players add ...")`
2260
+
2261
+ Sandstone exports `scoreboard`, `give`, `clear`, `execute`, `Selector`, `_.if` etc. as typed builders — use them. The `raw()` string skips type-checking, hard-codes objective names so a `score` rename silently breaks the command, and is harder to spot in code review. For per-target score updates from a global context, use `execute.as(Selector("@a", { tag: alice })).run(() => score.add(10))` — `@s` resolves correctly inside the `run` body.
2262
+
2263
+ ### Roles get tagged with the bare role name, not `kradle_<role>`
2264
+
2265
+ If your challenge has `roles: ["alice", "bob"] as const`, the kradle runtime adds `kradle_participant` plus the bare role name (`alice`, `bob`) as tags. Selectors filtering by `tag: "kradle_alice"` match nobody. Use `tag: "alice"`.
2266
+
1624
2267
  ---
1625
2268
 
1626
2269
  ## Tips for LLMs
1627
2270
 
1628
2271
  1. **Always use `as const`** for roles array to get proper type inference
1629
2272
  2. **Updaters should be idempotent** - they run every tick
1630
- 3. **Use `forEveryPlayer` for global aggregations** (max, count, any/all checks)
2273
+ 3. **Reach for the helper before hand-rolling an aggregation** — `Actions.maxPlayerScore` for cross-player max, `Actions.countEntities` for entity counts. Drop to `forEveryPlayer` only when no helper covers the shape.
1631
2274
  4. **Import `_` from sandstone** for conditions (`_.if`, `_.and`, `_.or`)
1632
2275
  5. **Variables are Scores** - use `.set()`, `.add()`, comparison methods
1633
2276
  6. **Main score is displayed** - sync your primary metric to `main_score`