@kradle/challenges-sdk 0.5.3 → 0.6.0

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,37 @@ 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
+
961
1248
  ### Player Attributes
962
1249
 
963
1250
  #### `Actions.setAttribute(params)`
@@ -1041,6 +1328,36 @@ Actions.setEndState({ endState: "defeat" });
1041
1328
  Actions.setEndState({ endState: "unknown" }); // Error: Invalid end state "unknown"
1042
1329
  ```
1043
1330
 
1331
+ #### `Actions.setEndStates(map): ConditionType`
1332
+
1333
+ 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.
1334
+
1335
+ 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).
1336
+
1337
+ ```typescript
1338
+ Actions.setEndStates({
1339
+ [endStateName: string]: ConditionType,
1340
+ }): ConditionType;
1341
+ ```
1342
+
1343
+ ```typescript
1344
+ let endReached: ConditionType | undefined;
1345
+ challenge
1346
+ .events(({ goal_broken, alive_players, game_timer }) => ({
1347
+ on_tick: () => {
1348
+ endReached = Actions.setEndStates({
1349
+ goal_reached: goal_broken.equalTo(1),
1350
+ time_up: _.or(alive_players.equalTo(0), game_timer.greaterOrEqualThan(GAME_DURATION)),
1351
+ });
1352
+ },
1353
+ }))
1354
+ .end_condition(() => endReached!);
1355
+ ```
1356
+
1357
+ End-state names are validated against `config.endStates` at build time, same as direct `Actions.setEndState` calls.
1358
+
1359
+ **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.
1360
+
1044
1361
  ---
1045
1362
 
1046
1363
  ## Utilities
@@ -1059,6 +1376,24 @@ forEveryPlayer(() => {
1059
1376
  });
1060
1377
  ```
1061
1378
 
1379
+ ### `forEachOtherPlayer(callback)`
1380
+
1381
+ 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.
1382
+
1383
+ 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`.
1384
+
1385
+ ```typescript
1386
+ import { forEveryPlayer, forEachOtherPlayer } from "@kradle/challenges-sdk";
1387
+
1388
+ forEveryPlayer(() => {
1389
+ forEachOtherPlayer(() => {
1390
+ // @s = the inner player; @a[tag=kradle_pair_self] = the outer player.
1391
+ // Useful for matchup-style logic: prisoner's dilemma payoffs, alliance
1392
+ // detection, "did I bump into another player" events, etc.
1393
+ });
1394
+ });
1395
+ ```
1396
+
1062
1397
  **Important Notes:**
1063
1398
  - Individual variables automatically reference the current player (`@s`) within the loop
1064
1399
  - Global variables remain global and are the same across all iterations
@@ -1259,12 +1594,8 @@ createChallenge({
1259
1594
  max_height_global: {
1260
1595
  type: "global",
1261
1596
  updater: (value, { max_height }) => {
1262
- value.set(0);
1263
- forEveryPlayer(() => {
1264
- _.if(max_height.greaterThan(value), () => {
1265
- value.set(max_height);
1266
- });
1267
- });
1597
+ // Pass `min: -1000` because climber Y can go below 0 (lava drops, void).
1598
+ value.set(Actions.maxPlayerScore({ score: max_height, min: -1000 }));
1268
1599
  },
1269
1600
  },
1270
1601
  is_winner: {
@@ -1384,12 +1715,7 @@ createChallenge({
1384
1715
  pig_killed_max: {
1385
1716
  type: "global",
1386
1717
  updater: (value, { pigs_killed }) => {
1387
- value.set(0);
1388
- forEveryPlayer(() => {
1389
- _.if(pigs_killed.greaterThan(value), () => {
1390
- value.set(pigs_killed);
1391
- });
1392
- });
1718
+ value.set(Actions.maxPlayerScore({ score: pigs_killed }));
1393
1719
  },
1394
1720
  },
1395
1721
  },
@@ -1489,6 +1815,22 @@ createChallenge({
1489
1815
 
1490
1816
  ## Common Patterns
1491
1817
 
1818
+ | # | Pattern | Use when | Canonical challenge |
1819
+ |---|---------|----------|---------------------|
1820
+ | 1 | Sync variable to main_score | Score should display on the watcher leaderboard | sprint, patience |
1821
+ | 2 | Find global maximum | "Highest score wins" comparisons | crafters-race, splat-rush |
1822
+ | 3 | Winner detection (has max + alive) | Latched is_winner that requires surviving | prisoners-dilemma, king-of-the-hill |
1823
+ | 4 | Track player position | Per-player coordinate snapshot for distance/region checks | sprint, lighthouse |
1824
+ | 5 | Check inventory for item | "Has the agent crafted X yet?" | crafters-race, biome-bazaar |
1825
+ | 6 | Check block below player | "Is the agent on a specific tile?" — use `Actions.standingOnBlock` | sacrifice, reputation |
1826
+ | 7 | Count entities | Wave / mob accounting | wave-survivor, zombie-survival |
1827
+ | 8 | Simple end condition | Single-condition timer or score gate | sprint |
1828
+ | 9 | Multi-condition end | OR of timer / score / event gates | crafters-race |
1829
+ | 10 | Opposing-team win conditions | Capture-the-flag style 0/1 flag state | castle-ctf, capture-the-flag |
1830
+ | 11 | Per-participant win attribution inside a single role | Same role, but only some win (saboteur vs miners) | saboteur |
1831
+ | 12 | Sequential turn-taking | "One player acts at a time" — current_voter gate | cascade, adaptive |
1832
+ | 13 | Two-phase game (sequential broadcast → simultaneous decision) | Vote after public discussion | common-knowledge |
1833
+
1492
1834
  ### Pattern 1: Sync Variable to Main Score
1493
1835
 
1494
1836
  ```typescript
@@ -1507,16 +1849,13 @@ my_variable: {
1507
1849
  max_global: {
1508
1850
  type: "global",
1509
1851
  updater: (value, { individual_score }) => {
1510
- value.set(0);
1511
- forEveryPlayer(() => {
1512
- _.if(individual_score.greaterThan(value), () => {
1513
- value.set(individual_score);
1514
- });
1515
- });
1852
+ value.set(Actions.maxPlayerScore({ score: individual_score }));
1516
1853
  },
1517
1854
  }
1518
1855
  ```
1519
1856
 
1857
+ 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.
1858
+
1520
1859
  ### Pattern 3: Winner Detection (Has Max Score + Alive)
1521
1860
 
1522
1861
  ```typescript
@@ -1587,14 +1926,13 @@ on_gold_block: {
1587
1926
  zombie_count: {
1588
1927
  type: "global",
1589
1928
  updater: (value) => {
1590
- value.set(0);
1591
- execute.as(Selector("@e", { type: "zombie" })).run(() => {
1592
- value.add(1);
1593
- });
1929
+ value.set(Actions.countEntities({ entity: "minecraft:zombie" }));
1594
1930
  },
1595
1931
  }
1596
1932
  ```
1597
1933
 
1934
+ 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).
1935
+
1598
1936
  ### Pattern 8: Simple End Condition
1599
1937
 
1600
1938
  ```typescript
@@ -1621,13 +1959,226 @@ zombie_count: {
1621
1959
  }))
1622
1960
  ```
1623
1961
 
1962
+ ### Pattern 11: Per-participant Win Attribution Inside a Single Role
1963
+
1964
+ 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 })`:
1965
+
1966
+ ```typescript
1967
+ .win_conditions(({ saboteur_alive, diamonds_mined }, { player }) => ({
1968
+ [player]: _.or(
1969
+ _.and(
1970
+ Selector("@s", { tag: "saboteur" } as any),
1971
+ saboteur_alive.equalTo(1),
1972
+ diamonds_mined.lowerThan(4),
1973
+ ),
1974
+ _.and(
1975
+ Selector("@s", { tag: "!saboteur" } as any),
1976
+ _.or(diamonds_mined.greaterOrEqualThan(4), saboteur_alive.equalTo(0)),
1977
+ ),
1978
+ ),
1979
+ }))
1980
+ ```
1981
+
1982
+ 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.
1983
+
1984
+ ### Pattern 12: Sequential turn-taking (one player acts at a time)
1985
+
1986
+ 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:
1987
+
1988
+ ```typescript
1989
+ custom_variables: {
1990
+ // ... your other variables ...
1991
+ current_voter: { type: "global", default: 1 },
1992
+ has_voted: { type: "individual", objective_type: "dummy", default: 0 },
1993
+ vote: {
1994
+ type: "individual",
1995
+ objective_type: "dummy",
1996
+ default: -1,
1997
+ updater: (value, { current_voter, has_voted, player_number }) => {
1998
+ forEveryPlayer(() => {
1999
+ _.if(
2000
+ _.and(has_voted.equalTo(0), player_number.equalTo(current_voter)),
2001
+ () => {
2002
+ _.if(Actions.standingOnBlock(RED_PAD), () => {
2003
+ value.set(0);
2004
+ has_voted.set(1);
2005
+ current_voter.add(1);
2006
+ });
2007
+ _.if(Actions.standingOnBlock(BLUE_PAD), () => {
2008
+ value.set(1);
2009
+ has_voted.set(1);
2010
+ current_voter.add(1);
2011
+ });
2012
+ },
2013
+ );
2014
+ });
2015
+ },
2016
+ },
2017
+ },
2018
+ // Announce each turn boundary via custom_events keyed on current_voter:
2019
+ .custom_events(({ current_voter }) => [
2020
+ { score: current_voter, target: 2, mode: "fire_once",
2021
+ actions: () => Actions.announce({ message: [{ text: "Voter 2's turn.", color: "yellow" }] }) },
2022
+ { score: current_voter, target: 3, mode: "fire_once",
2023
+ actions: () => Actions.announce({ message: [{ text: "Voter 3's turn.", color: "yellow" }] }) },
2024
+ // ... one per voter beyond the first ...
2025
+ ])
2026
+ ```
2027
+
2028
+ 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.
2029
+
2030
+ ### Pattern 13: Two-phase game (sequential broadcast, then simultaneous decision)
2031
+
2032
+ 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:
2033
+
2034
+ ```typescript
2035
+ custom_variables: {
2036
+ // ... phase 1: same as Pattern 12 ...
2037
+ broadcasts_done: {
2038
+ type: "global",
2039
+ default: 0,
2040
+ updater: (value, { has_broadcast }) => {
2041
+ value.set(0);
2042
+ forEveryPlayer(() => { value.add(has_broadcast); });
2043
+ },
2044
+ },
2045
+ has_voted_final: { type: "individual", objective_type: "dummy", default: 0 },
2046
+ final_vote: {
2047
+ type: "individual",
2048
+ objective_type: "dummy",
2049
+ default: -1,
2050
+ updater: (value, { broadcasts_done, has_voted_final, player_number }) => {
2051
+ // Per-player room layout — keeps each player's vote private until all cast.
2052
+ const FINAL_PADS = [
2053
+ { red: FINAL_RED_P1, blue: FINAL_BLUE_P1 },
2054
+ { red: FINAL_RED_P2, blue: FINAL_BLUE_P2 },
2055
+ { red: FINAL_RED_P3, blue: FINAL_BLUE_P3 },
2056
+ ];
2057
+ forEveryPlayer(() => {
2058
+ _.if(
2059
+ _.and(broadcasts_done.equalTo(3), has_voted_final.equalTo(0)),
2060
+ () => {
2061
+ FINAL_PADS.forEach((pads, idx) => {
2062
+ _.if(player_number.equalTo(idx + 1), () => {
2063
+ _.if(Actions.standingOnBlock(pads.red), () => {
2064
+ value.set(0);
2065
+ has_voted_final.set(1);
2066
+ });
2067
+ _.if(Actions.standingOnBlock(pads.blue), () => {
2068
+ value.set(1);
2069
+ has_voted_final.set(1);
2070
+ });
2071
+ });
2072
+ });
2073
+ },
2074
+ );
2075
+ });
2076
+ },
2077
+ },
2078
+ },
2079
+ // In init_participants, tell each player their player_number — without it, agents can't tell
2080
+ // which "Voter 2's turn" announcement is theirs and can't navigate to the right phase-2 room.
2081
+ // One tellraw to "all" with player_number embedded does it (each recipient sees their own value):
2082
+ // Actions.tellraw({ target: "all", message: ["Your player number is ", player_number] });
2083
+ ```
2084
+
2085
+ **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.
2086
+
2087
+ ---
2088
+
2089
+ ## Common Gotchas
2090
+
2091
+ 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.
2092
+
2093
+ ### Relative coords in global custom_variable updaters resolve at world origin (0, 0, 0)
2094
+
2095
+ `_.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.
2096
+
2097
+ **Fix:** wrap the updater body in `forEveryPlayer(() => { ... })` so the relative coords resolve at `@s` (each player's position):
2098
+
2099
+ ```ts
2100
+ trigger_pressed: {
2101
+ type: "global",
2102
+ default: 0,
2103
+ updater: (value) => {
2104
+ forEveryPlayer(() => {
2105
+ _.if(_.block(rel(0, -1, 0), "minecraft:gold_block"), () => {
2106
+ value.set(1);
2107
+ });
2108
+ });
2109
+ },
2110
+ }
2111
+ ```
2112
+
2113
+ This bit sequence-pad's wool-pad detection when the updater ran at world origin instead of per-player.
2114
+
2115
+ ### Sandstone NBT serialization: `true` → `{}`, `false` → `0b`
2116
+
2117
+ 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.
2118
+
2119
+ **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.
2120
+
2121
+ ### `Actions.tagEntity?.(...)` / `Actions.tagPlayer` don't exist
2122
+
2123
+ 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.
2124
+
2125
+ **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.
2126
+
2127
+ ### `score.lessThan` doesn't exist; use `lowerThan`
2128
+
2129
+ 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).
2130
+
2131
+ ### `scheduled_events` / `setInterval`-style helpers don't exist
2132
+
2133
+ 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(...)`.
2134
+
2135
+ ### `gamerule "pvp"` is not a real gamerule in 1.20.4
2136
+
2137
+ PvP is on by default in flat-world. Spyglass validation rejects this rule. Just don't set it.
2138
+
2139
+ ### End-state names are validated at build time against `config.ts → endStates`
2140
+
2141
+ `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.
2142
+
2143
+ ### `events.on_tick` and `.custom_events` run as the server entity, not as participants
2144
+
2145
+ 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.
2146
+
2147
+ ### `Selector("@s", { x, y, z })` does NOT constrain position
2148
+
2149
+ 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.
2150
+
2151
+ **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:
2152
+
2153
+ ```typescript
2154
+ // ❌ Silently passes for every entity:
2155
+ Selector("@s", { tag: "alice", x: PAD.x, y: PAD.y + 1, z: PAD.z })
2156
+
2157
+ // ✅ Matches only when feet are at (PAD.x, PAD.y+1, PAD.z):
2158
+ Selector("@s", { tag: "alice", x: PAD.x, y: PAD.y + 1, z: PAD.z, dx: 0, dy: 1, dz: 0 })
2159
+ // or — preferred:
2160
+ Actions.standingOnBlock({ x: PAD.x, y: PAD.y, z: PAD.z, role: "alice" })
2161
+ ```
2162
+
2163
+ ### `minecraft.picked_up:<item>` does NOT fire on `Actions.give` (also: does NOT decrement on `clear`)
2164
+
2165
+ 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).
2166
+
2167
+ ### Don't reach for `raw("scoreboard players add ...")`
2168
+
2169
+ 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.
2170
+
2171
+ ### Roles get tagged with the bare role name, not `kradle_<role>`
2172
+
2173
+ 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"`.
2174
+
1624
2175
  ---
1625
2176
 
1626
2177
  ## Tips for LLMs
1627
2178
 
1628
2179
  1. **Always use `as const`** for roles array to get proper type inference
1629
2180
  2. **Updaters should be idempotent** - they run every tick
1630
- 3. **Use `forEveryPlayer` for global aggregations** (max, count, any/all checks)
2181
+ 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
2182
  4. **Import `_` from sandstone** for conditions (`_.if`, `_.and`, `_.or`)
1632
2183
  5. **Variables are Scores** - use `.set()`, `.add()`, comparison methods
1633
2184
  6. **Main score is displayed** - sync your primary metric to `main_score`