@kradle/challenges-sdk 0.9.0 → 0.11.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/LLM_README.md +235 -2340
  3. package/README.md +151 -571
  4. package/dist/index.d.ts +1 -4
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -6
  7. package/dist/index.js.map +1 -1
  8. package/dist/runtime/examples/capture-the-flag.d.ts +33 -0
  9. package/dist/runtime/examples/capture-the-flag.d.ts.map +1 -0
  10. package/dist/runtime/examples/capture-the-flag.js +79 -0
  11. package/dist/runtime/examples/capture-the-flag.js.map +1 -0
  12. package/dist/runtime/examples/climb.d.ts +34 -0
  13. package/dist/runtime/examples/climb.d.ts.map +1 -0
  14. package/dist/runtime/examples/climb.js +54 -0
  15. package/dist/runtime/examples/climb.js.map +1 -0
  16. package/dist/runtime/examples/four-bridges.d.ts +107 -0
  17. package/dist/runtime/examples/four-bridges.d.ts.map +1 -0
  18. package/dist/runtime/examples/four-bridges.js +294 -0
  19. package/dist/runtime/examples/four-bridges.js.map +1 -0
  20. package/dist/runtime/examples/herding.d.ts +59 -0
  21. package/dist/runtime/examples/herding.d.ts.map +1 -0
  22. package/dist/runtime/examples/herding.js +181 -0
  23. package/dist/runtime/examples/herding.js.map +1 -0
  24. package/dist/runtime/examples/skywars.d.ts +116 -0
  25. package/dist/runtime/examples/skywars.d.ts.map +1 -0
  26. package/dist/runtime/examples/skywars.js +253 -0
  27. package/dist/runtime/examples/skywars.js.map +1 -0
  28. package/dist/runtime/harness.d.ts +176 -0
  29. package/dist/runtime/harness.d.ts.map +1 -0
  30. package/dist/runtime/harness.js +441 -0
  31. package/dist/runtime/harness.js.map +1 -0
  32. package/dist/runtime/index.d.ts +2 -0
  33. package/dist/runtime/index.d.ts.map +1 -0
  34. package/dist/runtime/index.js +18 -0
  35. package/dist/runtime/index.js.map +1 -0
  36. package/dist/runtime/sandbox-registry.d.ts +7 -0
  37. package/dist/runtime/sandbox-registry.d.ts.map +1 -0
  38. package/dist/runtime/sandbox-registry.js +16 -0
  39. package/dist/runtime/sandbox-registry.js.map +1 -0
  40. package/dist/runtime/sandbox.d.ts +29 -0
  41. package/dist/runtime/sandbox.d.ts.map +1 -0
  42. package/dist/runtime/sandbox.js +70 -0
  43. package/dist/runtime/sandbox.js.map +1 -0
  44. package/dist/runtime/sdk.d.ts +1038 -0
  45. package/dist/runtime/sdk.d.ts.map +1 -0
  46. package/dist/runtime/sdk.js +1271 -0
  47. package/dist/runtime/sdk.js.map +1 -0
  48. package/dist/runtime/world.d.ts +141 -0
  49. package/dist/runtime/world.d.ts.map +1 -0
  50. package/dist/runtime/world.js +344 -0
  51. package/dist/runtime/world.js.map +1 -0
  52. package/package.json +76 -39
  53. package/dist/actions.d.ts +0 -901
  54. package/dist/actions.d.ts.map +0 -1
  55. package/dist/actions.js +0 -1155
  56. package/dist/actions.js.map +0 -1
  57. package/dist/api_utils.d.ts +0 -25
  58. package/dist/api_utils.d.ts.map +0 -1
  59. package/dist/api_utils.js +0 -48
  60. package/dist/api_utils.js.map +0 -1
  61. package/dist/challenge.d.ts +0 -58
  62. package/dist/challenge.d.ts.map +0 -1
  63. package/dist/challenge.js +0 -578
  64. package/dist/challenge.js.map +0 -1
  65. package/dist/commands.d.ts +0 -57
  66. package/dist/commands.d.ts.map +0 -1
  67. package/dist/commands.js +0 -200
  68. package/dist/commands.js.map +0 -1
  69. package/dist/constants.d.ts +0 -72
  70. package/dist/constants.d.ts.map +0 -1
  71. package/dist/constants.js +0 -109
  72. package/dist/constants.js.map +0 -1
  73. package/dist/testmode.d.ts +0 -3
  74. package/dist/testmode.d.ts.map +0 -1
  75. package/dist/testmode.js +0 -19
  76. package/dist/testmode.js.map +0 -1
  77. package/dist/types.d.ts +0 -197
  78. package/dist/types.d.ts.map +0 -1
  79. package/dist/types.js +0 -38
  80. package/dist/types.js.map +0 -1
  81. package/dist/utils.d.ts +0 -17
  82. package/dist/utils.d.ts.map +0 -1
  83. package/dist/utils.js +0 -22
  84. package/dist/utils.js.map +0 -1
package/LLM_README.md CHANGED
@@ -1,2451 +1,346 @@
1
1
  # LLM_README.md - @kradle/challenges-sdk API Reference
2
2
 
3
- This document provides exhaustive API documentation for AI agents using the `@kradle/challenges-sdk` package. This is the complete reference for creating Minecraft datapack-based challenges.
3
+ This document is the exhaustive API reference for AI agents that write Kradle challenges with `@kradle/challenges-sdk`. A challenge is a reducer: a pure function that the arena runs during the game inside a sandbox. Read this file before you write a challenge.
4
4
 
5
5
  ## Table of Contents
6
6
 
7
- 1. [Helpers — start here](#helpers--start-here)
8
- 2. [Overview](#overview)
9
- 3. [Installation](#installation)
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
- 14. [Tips for LLMs](#tips-for-llms)
7
+ 1. [Overview](#overview)
8
+ 2. [Installation](#installation)
9
+ 3. [Core Concepts](#core-concepts)
10
+ 4. [defineChallenge API](#definechallenge-api)
11
+ 5. [The Spec](#the-spec)
12
+ 6. [Handlers](#handlers)
13
+ 7. [The Context](#the-context)
14
+ 8. [Players and Entities](#players-and-entities)
15
+ 9. [Rules of the Reducer](#rules-of-the-reducer)
16
+ 10. [Testing](#testing)
17
+ 11. [Build and Run](#build-and-run)
18
+ 12. [Complete Examples](#complete-examples)
19
+ 13. [Common Patterns](#common-patterns)
20
+ 14. [Common Gotchas](#common-gotchas)
21
+ 15. [Tips for LLMs](#tips-for-llms)
21
22
 
22
23
  ---
23
24
 
24
- ## Helpers — start here
25
-
26
- 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.
27
-
28
- | Helper | What it does | Use instead of |
29
- |--------|--------------|----------------|
30
- | **`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 |
31
- | **`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 |
32
- | **`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 |
33
- | **`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 |
34
- | **`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 |
35
- | **`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 …")` |
36
- | **`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))` |
37
- | **`Actions.knockbackWithLevitation({ target, mode, durationSeconds, amplifier })`** | Levitation (or levitation + slow-fall) effect for non-lethal displacement. | Hand-rolled `effect give … levitation` |
38
-
39
- Worked example using `Actions.box`:
40
-
41
- ```typescript
42
- import { Actions } from "@kradle/challenges-sdk";
43
-
44
- // 17×17 stone arena, 4-block walls, no roof — what boss-solo / melee-arena need.
45
- Actions.box({
46
- block: "minecraft:stone",
47
- from: { x: -8, y: -60, z: -8 },
48
- to: { x: 8, y: -56, z: 8 },
49
- absolute: true,
50
- hollow: true,
51
- floor: "minecraft:stone",
52
- });
53
- ```
54
-
55
- ---
56
-
57
- ## Overview
58
-
59
- `@kradle/challenges-sdk` is a TypeScript framework for creating Minecraft challenges that compile to datapacks. It provides:
60
-
61
- - **Variable System**: Track per-player and global game state with automatic tick updates
62
- - **Event System**: Lifecycle hooks and custom event triggers based on scores/advancements
63
- - **Actions Library**: Pre-built game operations (give items, teleport, announce, etc.)
64
- - **Role Management**: Assign players to teams with role-specific win conditions
65
- - **Sandstone Integration**: Full access to Sandstone's Minecraft command generation
66
-
67
- **Key Principle**: Challenges are defined declaratively. You specify variables, events, and conditions - the framework generates the datapack.
68
-
69
- ---
70
-
71
- ## Installation
72
-
73
- ```bash
74
- npm install @kradle/challenges-sdk sandstone@0.14.0-alpha.13
75
- ```
76
-
77
- **Requirements:**
78
- - Node.js >= 22.18.0
79
- - Sandstone 0.14.0-alpha.13 (peer dependency)
80
-
81
- ---
82
-
83
- ## Core Concepts
84
-
85
- ### Ticks
86
-
87
- Minecraft runs at 20 ticks per second. Time values in this API are in ticks:
88
- - 1 second = 20 ticks
89
- - 1 minute = 1200 ticks (60 * 20)
90
- - 5 minutes = 6000 ticks
91
-
92
- ### Namespace & Item IDs
93
-
94
- **IMPORTANT:** Most Actions require the full `minecraft:` prefix for items, blocks, and entities:
95
- - ✅ Correct: `item: "minecraft:diamond_sword"`
96
- - ❌ Wrong: `item: "diamond_sword"`
97
-
98
- The only exceptions are when the API explicitly accepts an item without the namespace (check each Action's documentation).
99
-
100
- **Examples:**
101
- - Items: `"minecraft:diamond"`, `"minecraft:iron_sword"`, `"minecraft:cooked_beef"`
102
- - Blocks: `"minecraft:stone"`, `"minecraft:diamond_block"`
103
- - Entities: `"minecraft:zombie"`, `"minecraft:pig"`, `"minecraft:creeper"`
104
-
105
- ### Scores
106
-
107
- All variables are backed by Minecraft scoreboards. The `Score` type from Sandstone represents a scoreboard value. Scores support comparison methods:
108
- - `score.equalTo(value)` / `score.equalTo(otherScore)`
109
- - `score.greaterThan(value)`
110
- - `score.greaterOrEqualThan(value)`
111
- - `score.lowerThan(value)`
112
- - `score.lowerOrEqualThan(value)`
113
-
114
- ### Roles
115
-
116
- Roles define player groups (e.g., teams). Each role can have different win conditions. All players assigned to a role share that role's win condition.
117
-
118
- ---
119
-
120
- ## createChallenge API
121
-
122
- ### Required Imports
123
-
124
- ```typescript
125
- import { createChallenge, Actions, forEveryPlayer } from "@kradle/challenges-sdk";
126
- import { _, execute, Selector, rel, abs } from "sandstone";
127
- import type { Score } from "sandstone";
128
- ```
129
-
130
- ### Signature
131
-
132
- ```typescript
133
- function createChallenge<
134
- ROLES extends readonly string[],
135
- VARIABLES extends Record<string, _InputVariableType>
136
- >(config: _BaseConfig<ROLES, VARIABLES>): ChallengeBuilder
137
- ```
138
-
139
- ### Configuration Object
140
-
141
- ```typescript
142
- interface _BaseConfig<ROLES, VARIABLES> {
143
- // Required: Challenge name (used for datapack namespace)
144
- name: string;
145
-
146
- // Deprecated & ignored: the CLI manages the output path via KRADLE_CHALLENGES_PATH.
147
- // Do not set it — setting it logs a warning at build time.
148
- kradle_challenge_path?: string;
149
-
150
- // Required: Player roles as readonly tuple
151
- // Example: ["attacker", "defender"] as const
152
- roles: ROLES;
153
-
154
- // Required: Custom variable definitions
155
- custom_variables: VARIABLES;
156
-
157
- // Optional: Game duration in ticks. If not set (or 0), no time limit.
158
- GAME_DURATION?: number;
159
-
160
- // Optional: World spawn point for the challenge
161
- // Can be explicit coordinates or an entity selector (e.g., from Actions.getLocation)
162
- spawnPoint?: { x: number; y: number; z: number } | SelectorClass;
163
-
164
- // Optional: Per-rule overrides for the implicit setup the SDK applies
165
- // inside start_challenge / init_participants. See "Implicit setup" below.
166
- defaults?: {
167
- time?: "day" | "noon" | "night" | "midnight" | false; // default: "day"
168
- daylightCycle?: boolean; // default: false
169
- mobSpawning?: boolean; // default: false
170
- naturalRegeneration?: boolean; // default: true
171
- clearInventory?: boolean; // default: true
172
- maxHealth?: number | false; // default: 20
173
- } | false;
174
- }
175
- ```
176
-
177
- ### Implicit setup (`defaults`)
178
-
179
- `createChallenge` applies a small, predictable setup at the top of `start_challenge` and `init_participants` so user code doesn't have to:
180
-
181
- - **`start_challenge`** — `time set day`, `gamerule doDaylightCycle false`, `gamerule doMobSpawning false`, `gamerule naturalRegeneration true`.
182
- - **`init_participants`** — `clear @a[participant]`, `attribute generic.max_health base set 20`.
183
-
184
- Override per-rule when needed:
185
-
186
- ```typescript
187
- // Wave-defense: keep mob spawns on
188
- createChallenge({ defaults: { mobSpawning: true }, ... })
189
-
190
- // PvP arena that pre-stocks inventory in start_challenge: keep what was given
191
- createChallenge({ defaults: { clearInventory: false }, ... })
192
-
193
- // Boss fight: 30 HP per agent
194
- createChallenge({ defaults: { maxHealth: 30 }, ... })
195
-
196
- // Tight combat tuning: no idle healing
197
- createChallenge({ defaults: { naturalRegeneration: false }, ... })
198
- ```
199
-
200
- Set `defaults: false` to disable implicit setup entirely (you're then responsible for time, gamerules, and per-player init):
201
-
202
- ```typescript
203
- createChallenge({ defaults: false, ... })
204
- ```
205
-
206
- **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.
207
-
208
- **Spawn Point:** The `spawnPoint` option sets the world spawn point and teleports new players to it. It accepts either:
209
- - Explicit coordinates: `{ x: 0, y: 64, z: 0 }`
210
- - An entity selector (e.g., from `Actions.getLocation`): `Actions.getLocation({ name: "spawn" })`
211
-
212
- When using an entity selector, the spawn point will be set at the entity's location once it exists in the world. New players are automatically teleported to the spawn point and have their individual spawn point set there.
213
-
214
- ```typescript
215
- // Using explicit coordinates
216
- createChallenge({
217
- name: "my-challenge",
218
- roles: ["player"] as const,
219
- spawnPoint: { x: 0, y: 100, z: 0 },
220
- custom_variables: {},
221
- });
222
-
223
- // Using a location marker (preferred for map-based challenges)
224
- createChallenge({
225
- name: "my-challenge",
226
- roles: ["player"] as const,
227
- spawnPoint: Actions.getLocation({ name: "spawn" }),
228
- custom_variables: {},
229
- });
230
- ```
231
-
232
- ### Builder Methods
233
-
234
- The builder uses a fluent API. Methods must be called in order:
235
-
236
- ```typescript
237
- createChallenge(config)
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)
242
- ```
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
-
246
- #### `.events(callback)`
247
-
248
- ```typescript
249
- .events((variables: Variables, roles: Roles) => {
250
- return {
251
- start_challenge?: () => void; // Runs once when game starts
252
- init_participants?: () => void; // Runs once per player after start
253
- on_tick?: () => void; // Runs every tick for each player
254
- end_challenge?: () => void; // Runs once when game ends
255
- };
256
- })
257
- ```
258
-
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`.
282
-
283
- ```typescript
284
- .custom_events((variables: Variables, roles: Roles) => {
285
- return Array<ScoreEvent | AdvancementEvent>;
286
- })
287
- ```
288
-
289
- **Score Event:**
290
- ```typescript
291
- {
292
- score: Score; // Variable to watch
293
- target: number; // Threshold value
294
- mode: "fire_once" | "repeatable";
295
- actions: () => void; // Actions to execute
296
- }
297
- ```
298
-
299
- **Advancement Event:**
300
- ```typescript
301
- {
302
- criteria: Array<{ trigger: string; conditions?: object }>;
303
- mode: "fire_once" | "repeatable";
304
- actions: () => void;
305
- }
306
- ```
307
-
308
- **Execution context:** for **individual** scores/advancements the `actions` callback runs `as` *and* `at` each matching player — `@s` is the player and relative coords (`~ ~ ~`) / `distance=` selectors resolve from the player's position. **Global** scores run once in the server context (no `@s`, no position); wrap per-player work in `forEveryPlayer(...)`.
309
-
310
- #### `.end_condition(callback)`
311
-
312
- 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.
313
-
314
- ```typescript
315
- .end_condition((variables: Variables, roles: Roles) => {
316
- return Condition; // Returns a Sandstone condition
317
- })
318
- ```
319
-
320
- #### `.win_conditions(callback)`
321
-
322
- ```typescript
323
- .win_conditions((variables: Variables, roles: Roles) => {
324
- return {
325
- [roleName: string]: Condition; // One condition per role
326
- };
327
- })
328
- ```
329
-
330
- ---
331
-
332
- ## Variables
333
-
334
- ### Built-in Variables
335
-
336
- These are always available in every challenge:
337
-
338
- | Variable | Type | Description |
339
- |----------|------|-------------|
340
- | `death_count` | individual | Player's death count (Minecraft `deathCount` objective) |
341
- | `has_never_died` | individual | 1 if player has never died, 0 otherwise |
342
- | `alive_players` | global | Count of living participants |
343
- | `main_score` | individual | Primary score shown on sidebar |
344
- | `game_timer` | global | Ticks since game start (increments each tick) |
345
- | `game_state` | global | 0=CREATED, 1=OFF, 2=ON |
346
- | `player_count` | global | Total participant count |
347
- | `player_number` | individual | Unique player ID (1 to N) |
348
-
349
- ### Custom Variable Definition
350
-
351
- ```typescript
352
- custom_variables: {
353
- variable_name: {
354
- type: "individual" | "global";
355
- objective_type?: string; // Minecraft objective criterion
356
- hidden?: boolean; // Hide from scoreboard (global only)
357
- default?: number; // Initial value
358
- updater?: UpdaterFunction;
359
- }
360
- }
361
- ```
362
-
363
- ### Variable Types
364
-
365
- #### Individual Objective-Based
366
-
367
- Automatically tracks Minecraft statistics:
368
-
369
- ```typescript
370
- pigs_killed: {
371
- type: "individual",
372
- objective_type: "minecraft.killed:minecraft.pig",
373
- default: 0,
374
- }
375
- ```
376
-
377
- Common objective types:
378
- - `"minecraft.killed:minecraft.<entity>"` - Entities killed
379
- - `"minecraft.killed_by:minecraft.<entity>"` - Killed by entity
380
- - `"minecraft.picked_up:minecraft.<item>"` - Items picked up
381
- - `"minecraft.mined:minecraft.<block>"` - Blocks mined
382
- - `"minecraft.used:minecraft.<item>"` - Items used
383
- - `"minecraft.crafted:minecraft.<item>"` - Items crafted
384
- - `"playerKillCount"` - PvP kills
385
- - `"deathCount"` - Deaths
386
- - `"dummy"` - Manual/computed value
387
-
388
- 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.
389
-
390
- > **⚠️ `minecraft.picked_up` only fires on item-entity pickup, NOT on `Actions.give`.**
391
- >
392
- > 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.
393
- >
394
- > Only one of these patterns will register on `picked_up`:
395
- > 1. Drop the item with `summon item ... ItemTags ...` so the player walks over it (slow + visible).
396
- > 2. Track the score yourself in a `dummy` objective and increment it at every payout site.
397
- >
398
- > Pattern 2 is almost always what you want for "emeralds awarded so far":
399
- > ```typescript
400
- > custom_variables: {
401
- > score: {
402
- > type: "individual",
403
- > objective_type: "dummy",
404
- > default: 0,
405
- > updater: (value, { main_score }) => main_score.set(value),
406
- > },
407
- > ticks_in_a: {
408
- > type: "individual",
409
- > objective_type: "dummy",
410
- > default: 0,
411
- > // The second argument to `updater` exposes every other custom_variable —
412
- > // including `score` — so you can bump it yourself wherever Actions.give fires.
413
- > updater: (value, { score }) => {
414
- > forEveryPlayer(() => {
415
- > _.if(<on_platform>, () => {
416
- > value.add(1);
417
- > _.if(value.modulo(20).equalTo(0), () => {
418
- > Actions.give({ target: "self", item: "minecraft:emerald", count: 1 });
419
- > score.add(1);
420
- > });
421
- > });
422
- > });
423
- > },
424
- > },
425
- > }
426
- > ```
427
- >
428
- > 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)`.
429
-
430
- #### Individual Dummy
431
-
432
- Computed per-player values:
433
-
434
- ```typescript
435
- current_y_position: {
436
- type: "individual",
437
- objective_type: "dummy",
438
- updater: (value) => {
439
- value.set(Actions.getCurrentPlayerPosition().y)
440
- },
441
- }
442
- ```
443
-
444
- #### Global Variables
445
-
446
- Shared across all players:
447
-
448
- ```typescript
449
- max_score_global: {
450
- type: "global",
451
- hidden: false,
452
- default: 0,
453
- updater: (value, { main_score }) => {
454
- value.set(0);
455
- forEveryPlayer(() => {
456
- _.if(main_score.greaterThan(value), () => {
457
- value.set(main_score);
458
- });
459
- });
460
- },
461
- }
462
- ```
463
-
464
- ### Updater Function Signature
465
-
466
- ```typescript
467
- type UpdaterFunction = (
468
- value: Score, // Current variable's score
469
- variables: Record<string, Score> // All variables including built-ins
470
- ) => void;
471
- ```
472
-
473
- Updaters run every tick. They should be idempotent. They are not necessary if the variable is individual and tracks a Minecraft objective.
474
-
475
- ### Score Methods
476
-
477
- All variables are `Score` objects with these methods:
478
-
479
- ```typescript
480
- // Set value
481
- score.set(number | Score);
482
-
483
- // Arithmetic
484
- score.add(number | Score);
485
- score.remove(number | Score);
486
-
487
- // Comparison (return Condition)
488
- score.equalTo(number | Score);
489
- score.greaterThan(number | Score);
490
- score.greaterOrEqualThan(number | Score);
491
- score.lowerThan(number | Score);
492
- score.lowerOrEqualThan(number | Score);
493
- ```
494
-
495
- 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.
496
-
497
- ---
498
-
499
- ## Events
500
-
501
- ### Lifecycle Events
502
-
503
- All lifecycle events run globally (not per-player).
504
-
505
- #### `start_challenge`
506
-
507
- Runs once when the challenge starts. Use for global setup.
508
-
509
- ```typescript
510
- start_challenge: () => {
511
- Actions.setTime({ time: "day" });
512
- Actions.gamerule({ rule: "doDaylightCycle", value: false });
513
- Actions.announce({ message: "Game starting!" });
514
- }
515
- ```
516
-
517
- #### `init_participants`
518
-
519
- Runs 1 second after start_challenge. Use for player setup. It still runs globally.
520
-
521
- ```typescript
522
- init_participants: () => {
523
- Actions.give({ target: "all", item: "minecraft:diamond_sword", count: 1 });
524
- Actions.setAttribute({ target: "all", attribute_: "generic.max_health", value: 40 });
525
- Actions.teleport({ target: "all", x: 0, y: 100, z: 0, absolute: true });
526
- }
527
- ```
528
-
529
- #### `on_tick`
530
-
531
- Runs every tick. Use sparingly for performance.
532
-
533
- ```typescript
534
- on_tick: () => {
535
- // Check something every tick
536
- }
537
- ```
538
-
539
- #### `end_challenge`
540
-
541
- Runs once when the game ends. Use for cleanup and announcements.
542
-
543
- ```typescript
544
- end_challenge: () => {
545
- Actions.announce({ message: "Game over!" });
546
- }
547
- ```
548
-
549
- ### Custom Events
550
-
551
- Custom events allow you to trigger actions based on score thresholds or Minecraft advancement criteria.
552
-
553
- #### Score-Based Events
554
-
555
- Score events watch a variable and trigger when it reaches a specified target value. They are evaluated every tick.
556
-
557
- **Parameters:**
558
- - `score` (required): The `Score` variable to watch
559
- - `target` (optional): The threshold value. If omitted, triggers on any score change
560
- - `mode` (required): `"fire_once"` or `"repeatable"`
561
- - `actions` (required): Function containing actions to execute
562
-
563
- **How it works:**
564
- 1. Every tick, the system compares the current score to the target
565
- 2. For `"fire_once"`: triggers once when `score == target` (tracks previous value to detect the 1st time threshold is met)
566
- 3. For `"repeatable"`: triggers every tick while `score == target`
567
- 4. For individual variables, events fire per-player; for global variables, events fire once globally
568
-
569
- ```typescript
570
- {
571
- score: variables.diamonds,
572
- target: 10,
573
- mode: "fire_once",
574
- actions: () => {
575
- Actions.announce({ message: "10 diamonds collected!" });
576
- },
577
- }
578
- ```
579
-
580
- **Without target (triggers on any change):**
581
- ```typescript
582
- {
583
- score: variables.death_count,
584
- mode: "fire_once", // Triggers once when death_count changes away from initial value
585
- actions: () => {
586
- Actions.announce({ message: "First death!" });
587
- },
588
- }
589
- ```
590
-
591
- **Modes:**
592
- - `"fire_once"`: Triggers once when threshold is reached (per player for individual variables). Uses previous tick comparison to detect when the score crosses the target.
593
- - `"repeatable"`: Triggers every tick while `score >= target`. Useful for continuous effects.
594
-
595
- #### Advancement-Based Events
596
-
597
- Advancement events trigger when a Minecraft advancement criterion is met. Internally, an advancement is created that grants when the criterion triggers, which then fires the event. The `criteria` array follows the [Minecraft Advancement JSON format](https://minecraft.fandom.com/wiki/Advancement/JSON_format).
598
-
599
- Advancement-based events always fire per-player.
600
-
601
- **Parameters:**
602
- - `criteria` (required): Array of advancement trigger objects with optional conditions
603
- - `mode` (required): `"fire_once"` or `"repeatable"`
604
- - `actions` (required): Function containing actions to execute
605
-
606
- **How it works:**
607
- 1. An advancement is generated with your specified criteria
608
- 2. When Minecraft grants the advancement (criterion met), the event fires
609
- 3. For `"repeatable"`: the advancement is automatically revoked so it can trigger again
610
- 4. For `"fire_once"`: the advancement stays granted, preventing re-triggering
611
-
612
- **Simple example - Track when a player hits another player:**
613
- ```typescript
614
- {
615
- criteria: [
616
- {
617
- trigger: "minecraft:player_hurt_entity",
618
- conditions: {
619
- entity: { type: "minecraft:player" }
620
- }
621
- }
622
- ],
623
- mode: "repeatable",
624
- actions: () => {
625
- Actions.increment({ variable: variables.pvp_hits });
626
- Actions.announce({ message: "PvP hit!" });
627
- },
628
- }
629
- ```
630
-
631
- **Multiple triggers example:**
632
- ```typescript
633
- {
634
- criteria: [
635
- { trigger: "minecraft:player_hurt_entity" },
636
- { trigger: "minecraft:entity_hurt_player" },
637
- ],
638
- mode: "repeatable",
639
- actions: () => {
640
- Actions.increment({ variable: variables.combat_actions });
641
- },
642
- }
643
- ```
644
-
645
- Common triggers:
646
- - `"minecraft:player_hurt_entity"` - Player attacks entity
647
- - `"minecraft:entity_hurt_player"` - Entity attacks player
648
- - `"minecraft:player_killed_entity"` - Player kills entity
649
- - `"minecraft:consume_item"` - Item consumed
650
- - `"minecraft:inventory_changed"` - Inventory changes
651
- - `"minecraft:location"` - Player at location
652
- - `"minecraft:enter_block"` - Player enters block
653
-
654
- See the [Minecraft Wiki](https://minecraft.fandom.com/wiki/Advancement/JSON_format#List_of_triggers) for the full list of triggers and their conditions.
655
-
656
- ---
657
-
658
- ## Actions
659
-
660
- Actions are higher-level functions that wrap common Minecraft operations. They provide:
661
- - Automatic target mapping (`"all"`, `"self"`, role names → proper selectors)
662
- - Integration with Kradle's interface (e.g., `Actions.announce` messages appear in Kradle)
663
- - Consistent API for common operations
664
-
665
- For advanced use cases not covered by Actions, you can fall back to Sandstone's lower-level functions directly (`give`, `tellraw`, `effect`, `kill`, `execute`, etc.). See [Sandstone Integration](#sandstone-integration).
666
-
667
- All actions are called via the `Actions` object:
668
-
669
- ```typescript
670
- import { Actions } from "@kradle/challenges-sdk";
671
- ```
672
-
673
- ### Target Parameter
674
-
675
- Many actions accept a `target` parameter of type `TargetNames`, which can be:
676
- - `"all"` - Targets all participants (maps to `@a[tag=kradle_participant]`)
677
- - `"self"` - Targets the current player (maps to `@s`)
678
- - A role name (e.g. `"red"`) - Targets all players in that role (maps to `@a[tag=red]` — roles are stored as player tags, not scoreboard teams)
679
- - An entity ID (e.g. `"minecraft:zombie"`) - Targets all entities of that type (maps to `@e[type=minecraft:zombie]`)
680
- - Any `Selector` instance - Custom selector for fine-grained targeting (e.g., `Selector("@a", { tag: "red" })`)
681
-
682
- ### Communication
683
-
684
- #### `Actions.announce(params)`
685
-
686
- Broadcast message to all players with KRADLE tag.
687
-
688
- ```typescript
689
- Actions.announce({
690
- message: JSONTextComponent; // Message (string or formatted object)
691
- });
692
-
693
- // Simple string:
694
- Actions.announce({ message: "Game starting!" });
695
-
696
- // Formatted JSONTextComponent:
697
- Actions.announce({
698
- message: [
699
- { text: "Player ", color: "white" },
700
- { selector: "@s", color: "gold", bold: true },
701
- { text: " won the game!", color: "green" }
702
- ]
703
- });
704
- ```
705
-
706
- #### `Actions.tellraw(params)`
707
-
708
- Send formatted message to specific target with KRADLE display tag.
709
-
710
- ```typescript
711
- Actions.tellraw({
712
- target: TargetNames; // "all", "self", or any selector
713
- message: JSONTextComponent; // Message (string or formatted object)
714
- });
715
-
716
- // Examples:
717
- Actions.tellraw({
718
- target: "all",
719
- message: ["Hello, ", { text: "world!", color: "gold", bold: true }]
720
- });
721
- Actions.tellraw({
722
- target: "self",
723
- message: "You won!"
724
- });
725
- Actions.tellraw({
726
- target: "self",
727
- message: { text: "Critical hit!", color: "red", bold: true }
728
- });
729
-
730
- // Score values embed directly into the message — Sandstone serializes them
731
- // as `{ score: { name, objective } }` and Minecraft renders each recipient's
732
- // own value. So a single tellraw to "all" shows each player THEIR number,
733
- // no `forEveryPlayer` + `_.if(player_number.equalTo(N))` branching needed:
734
- Actions.tellraw({
735
- target: "all",
736
- message: [{ text: "Your player number is ", color: "yellow" }, player_number],
737
- });
738
- ```
739
-
740
- ### Items & Inventory
741
-
742
- #### `Actions.give(params)`
743
-
744
- Give items to a target.
745
-
746
- ```typescript
747
- Actions.give({
748
- target: TargetNames; // "all", "self", or any selector
749
- item: string; // Item ID (with "minecraft:" prefix)
750
- count?: number; // Amount (default: 1)
751
- });
752
-
753
- // Examples:
754
- Actions.give({ target: "self", item: "minecraft:diamond_sword", count: 1 });
755
- Actions.give({ target: "all", item: "minecraft:diamond", count: 10 });
756
- Actions.give({ target: "red", item: "minecraft:iron_sword", count: 1 }); // Targets the "red" role (resolves to @a[tag=red])
757
- ```
758
-
759
- **Note:** The `target` parameter can be:
760
- - `"all"` - All participants
761
- - `"self"` - Current player (`@s`)
762
- - A role name - Targets all players in that role (roles are stored as tags, not scoreboard teams)
763
- - Any `Selector` instance for custom targeting
764
-
765
- #### `Actions.giveAll(items)`
766
-
767
- Give every participant a list of items in one call — replaces the run of `Actions.give({ target: "all", … })` calls that opens most multi-player `init_participants`. `count` defaults to 1. For role-specific equipment, use plain `Actions.give({ target: roleName, … })`.
768
-
769
- ```typescript
770
- // PvP standard kit:
771
- Actions.giveAll([
772
- { item: "minecraft:iron_sword" },
773
- { item: "minecraft:iron_helmet" },
774
- { item: "minecraft:cooked_beef", count: 5 },
775
- ]);
776
- ```
777
-
778
- #### `Actions.giveLoot(params)`
779
-
780
- Give random items from weighted loot table.
781
-
782
- ```typescript
783
- Actions.giveLoot({
784
- target: TargetNames; // "all", "self", or any selector
785
- items: [{ name: ITEMS; count: number; weight: number }]; // Weighted item list
786
- });
787
-
788
- // Example:
789
- Actions.giveLoot({
790
- target: "self",
791
- items: [
792
- { name: "minecraft:diamond", count: 5, weight: 1 },
793
- { name: "minecraft:iron_ingot", count: 10, weight: 3 },
794
- { name: "minecraft:gold_ingot", count: 7, weight: 2 }
795
- ]
796
- });
797
- ```
798
-
799
- **Note:** This creates a weighted loot table. Items with higher weights are more likely to be selected.
800
-
801
- #### `Actions.clear(params)`
802
-
803
- Clear all items from a target's inventory.
804
-
805
- ```typescript
806
- Actions.clear({
807
- target: TargetNames; // "all", "self", or any selector
808
- });
809
-
810
- // Examples:
811
- Actions.clear({ target: "self" }); // Clear current player's inventory
812
- Actions.clear({ target: "all" }); // Clear all participants' inventories
813
- ```
814
-
815
- #### `Actions.countItems(params)`
816
-
817
- Count the number of a specific item in a target's inventory. Creates and returns a temporary variable with the count. This is the prefered way of counting items.
818
-
819
- ```typescript
820
- Actions.countItems({
821
- target: TargetNames; // The target to count items for
822
- item: ITEMS; // The item to count
823
- }): Score; // Returns a new variable containing the count
824
-
825
- // Example - Use directly in conditions:
826
- _.if(Actions.countItems({ target: "self", item: "minecraft:diamond" }).greaterThan(5), () => {
827
- Actions.announce({ message: "You have more than 5 diamonds!" });
828
- });
829
-
830
- // Example - Store in a variable for later use:
831
- const diamondCount = Actions.countItems({ target: "self", item: "minecraft:diamond" });
832
- _.if(diamondCount.greaterThan(10), () => {
833
- Actions.announce({ message: "You have more than 10 diamonds!" });
834
- });
835
-
836
- // Example - Set a custom variable from the count:
837
- const count = Actions.countItems({ target: "self", item: "minecraft:diamond" });
838
- variables.my_diamond_count.set(count);
839
- ```
840
-
841
- **Note:** This action creates a temporary variable internally using `Variable()` and uses `execute.store.result.score` with `clear` command (count 0) to count items without removing them from the inventory.
842
-
843
- #### `Actions.getCurrentPlayerPosition()`
844
-
845
- Get the current player's position as x, y, z Score variables. Must be called in a player context (e.g., inside `forEveryPlayer`, individual variables updaters, or when `@s` is a player). This is the prefered way of checking a player's position.
846
-
847
- ```typescript
848
- Actions.getCurrentPlayerPosition(): { x: Score; y: Score; z: Score }
849
-
850
- // Example - Check if player is above Y=100:
851
- const pos = Actions.getCurrentPlayerPosition();
852
- _.if(pos.y.greaterThan(100), () => {
853
- Actions.announce({ message: "You reached the sky!" });
854
- });
855
-
856
- // Example - Store position in custom variables:
857
- const { x, y, z } = Actions.getCurrentPlayerPosition();
858
- variables.player_x.set(x);
859
- variables.player_y.set(y);
860
- variables.player_z.set(z);
861
-
862
- // Example - Check if player is in a specific area:
863
- const pos = Actions.getCurrentPlayerPosition();
864
- _.if(_.and(
865
- pos.x.greaterThan(0),
866
- pos.x.lowerThan(100),
867
- pos.z.greaterThan(0),
868
- pos.z.lowerThan(100)
869
- ), () => {
870
- Actions.announce({ message: "You're in the zone!" });
871
- });
872
- ```
873
-
874
- **Note:** This returns integer coordinates (block position). The values are truncated from the player's exact floating-point position.
875
-
876
- ### Entities
877
-
878
- #### `Actions.summonMultiple(params)`
879
-
880
- Summon multiple entities at location.
881
-
882
- ```typescript
883
- Actions.summonMultiple({
884
- entity: string; // Entity ID (with "minecraft:" prefix)
885
- count: number; // How many entities to summon
886
- x: number; // X coordinate
887
- y: number; // Y coordinate
888
- z: number; // Z coordinate
889
- absolute: boolean; // true for absolute coords, false for relative
890
- });
891
-
892
- // Example:
893
- Actions.summonMultiple({
894
- entity: "minecraft:zombie",
895
- count: 5,
896
- x: 0,
897
- y: 64,
898
- z: 0,
899
- absolute: true
900
- });
901
- ```
902
-
903
- #### `Actions.summonEntityWithStats(params)`
904
-
905
- Summon a single configurable entity with optional health, attack damage, movement speed, and tags — for boss-style mobs (use `summonMultiple` for plain entities). Serializes NBT correctly via Sandstone's typed helpers.
906
-
907
- ```typescript
908
- Actions.summonEntityWithStats({
909
- entity: "minecraft:zombie",
910
- x: 0, y: -60, z: 0, absolute: true,
911
- health: 60, // optional
912
- attackDamage: 6, // optional
913
- movementSpeed: 0.18, // optional
914
- tags: ["boss"], // optional — pairs with Actions.countEntities({ tag: "boss" })
915
- });
916
- ```
917
-
918
- #### `Actions.summonItem(params)`
919
-
920
- Summon item entity at location.
921
-
922
- ```typescript
923
- Actions.summonItem({
924
- item: string; // Item ID (with "minecraft:" prefix)
925
- x: number; // X coordinate
926
- y: number; // Y coordinate
927
- z: number; // Z coordinate
928
- absolute: boolean; // true for absolute coords, false for relative
929
- });
930
-
931
- // Example:
932
- Actions.summonItem({
933
- item: "minecraft:diamond",
934
- x: 0,
935
- y: 64,
936
- z: 0,
937
- absolute: true
938
- });
939
- ```
940
-
941
- #### `Actions.kill(params)`
942
-
943
- Kill entities matching selector.
944
-
945
- ```typescript
946
- Actions.kill({
947
- selector: TargetNames; // "all", "self", or any selector
948
- });
949
-
950
- // Examples:
951
- Actions.kill({ selector: Selector("@e", { type: "minecraft:zombie" }) });
952
- Actions.kill({ selector: Selector("@e", { type: "!minecraft:player" }) });
953
- Actions.kill({ selector: "all" }); // Kill all participants
954
- ```
955
-
956
- #### `Actions.eliminate(params)`
957
-
958
- Eliminate a participant from the active round by switching them to spectator mode — they can watch but not act, and their agent connection stays alive. Sister to `kill`, for vote-out / king-of-the-hill / battle-royale games where elimination shouldn't end the agent's session. Only flips gamemode: you own any per-player tracking variable (e.g. `is_alive`).
959
-
960
- ```typescript
961
- // target: TargetNames — "all", "self", or any selector
962
- Actions.eliminate({ target: "self" }); // inside forEveryPlayer
963
- ```
964
-
965
- #### `Actions.setGamemode(params)`
966
-
967
- Set a target's gamemode. The general form of `eliminate` — useful for moving players to `adventure` for a build-phase lockdown, or `spectator` without killing them.
968
-
969
- ```typescript
970
- Actions.setGamemode({
971
- target: TargetNames; // "all", "self", or any selector
972
- mode: "survival" | "creative" | "adventure" | "spectator";
973
- });
974
-
975
- Actions.setGamemode({ target: "all", mode: "adventure" });
976
- ```
977
-
978
- #### `Actions.assignRandomTag(params)`
979
-
980
- Pick `count` random participants and tag them — the hidden-role assigner for social-deduction challenges. Selection happens at command time (typically `start_challenge`) and is stable for the run. `count` is capped to the participant count. Combine with a targeted `Actions.tellraw` to privately notify the chosen players.
981
-
982
- ```typescript
983
- start_challenge: () => {
984
- Actions.assignRandomTag({ tag: "saboteur", count: 1 });
985
- Actions.tellraw({
986
- target: Selector("@a", { tag: "saboteur" }),
987
- message: [{ text: "You're the saboteur.", color: "red" }],
988
- });
989
- }
990
- ```
991
-
992
- #### `Actions.teleport(params)`
993
-
994
- Teleport entities to a location or to another entity.
995
-
996
- ```typescript
997
- // Teleport to coordinates
998
- Actions.teleport({
999
- target: TargetNames; // "all", "self", or any selector
1000
- x: number; // X coordinate
1001
- y: number; // Y coordinate
1002
- z: number; // Z coordinate
1003
- absolute: boolean; // true for absolute coords, false for relative
1004
- });
1005
-
1006
- // Teleport to entity
1007
- Actions.teleport({
1008
- target: TargetNames; // "all", "self", or any selector
1009
- toEntity: SingleEntityArgument; // Entity to teleport to
1010
- });
1011
-
1012
- // Examples:
1013
- Actions.teleport({ target: "self", x: 0, y: 100, z: 0, absolute: true });
1014
- Actions.teleport({ target: "all", x: 0, y: 64, z: 0, absolute: true });
1015
- Actions.teleport({ target: "self", x: 10, y: 0, z: 5, absolute: false }); // Relative position
1016
-
1017
- // Teleport to a location marker entity
1018
- const spawn = Actions.getLocation({ name: "spawn" });
1019
- Actions.teleport({ target: "all", toEntity: spawn });
1020
- ```
1021
-
1022
- #### `Actions.forceMoveTo(params)`
1023
-
1024
- Force the target player(s) to immediately interrupt whatever code they are running and **walk** to absolute coordinates. Server-initiated — the challenge forces the move, unlike the agent's own movement skills. The agents always walk and are **never teleported, even in cheat mode**. Only the interrupt plus a single pathfind are guaranteed; an agent may decide to walk away again on its next action.
1025
-
1026
- ```typescript
1027
- Actions.forceMoveTo({
1028
- target: TargetNames; // "self", "all", a role name, or any Selector. "self" resolves only inside forEveryPlayer; raw "@..." strings are rejected.
1029
- x: number; // Absolute X coordinate
1030
- y: number; // Absolute Y coordinate
1031
- z: number; // Absolute Z coordinate
1032
- });
1033
-
1034
- // Examples:
1035
- Actions.forceMoveTo({ target: "all", x: 0, y: -60, z: 0 });
1036
- Actions.forceMoveTo({ target: "red", x: 10, y: -60, z: 12 }); // everyone tagged "red"
1037
- Actions.forceMoveTo({ target: Selector("@a", { tag: "winner" }), x: 0, y: -60, z: 0 });
1038
- // "self" works only inside forEveryPlayer (per-player executor); a contextless
1039
- // forceMoveTo("self") fails the build's context-safety check.
1040
- forEveryPlayer(() => Actions.forceMoveTo({ target: "self", x: 0, y: -60, z: 0 }));
1041
- ```
1042
-
1043
- Coordinates are always absolute. Unlike `Actions.teleport`, this is a real pathfind (walk / jump / swim), so the destination must be reachable on foot. Each target's in-flight code is interrupted, and the agent is told it was force-moved. Use it to herd players into a phase-2 room, pull a winner onto a podium, reset positions between rounds, etc.
1044
-
1045
- #### `Actions.getLocation(params)`
1046
-
1047
- Get a location marker entity by name. Returns a selector targeting the marker entity with the specified location tag. Locations must be defined in `config.ts` under `challengeConfig.locations`.
1048
-
1049
- ```typescript
1050
- Actions.getLocation({
1051
- name: string; // The location name (must match a key in config.locations)
1052
- }): SelectorClass; // Returns a selector targeting the location marker
1053
-
1054
- // Example - Get a location and teleport to it:
1055
- const spawn = Actions.getLocation({ name: "spawn" });
1056
- Actions.teleport({ target: "all", toEntity: spawn });
1057
-
1058
- // Example - Use as spawnPoint in config:
1059
- createChallenge({
1060
- name: "my-challenge",
1061
- // ...
1062
- spawnPoint: Actions.getLocation({ name: "spawn" }),
1063
- });
1064
- ```
1065
-
1066
- **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.
1067
-
1068
- #### `Actions.box(params)`
1069
-
1070
- Build a box (4 walls + optional floor/roof) in a single call. Replaces the typical 5+ `Actions.fill` calls per arena.
1071
-
1072
- ```typescript
1073
- Actions.box({
1074
- block: BLOCKS; // Wall block
1075
- from: { x, y, z }; // First corner (inclusive)
1076
- to: { x, y, z }; // Opposite corner (inclusive)
1077
- absolute: boolean;
1078
- hollow?: boolean; // Default true: only walls placed
1079
- floor?: BLOCKS; // Optional override block for floor
1080
- roof?: BLOCKS; // Optional roof
1081
- clearInside?: BLOCKS | boolean; // true = air; supply a BLOCKS to fill interior
1082
- });
1083
-
1084
- // Example - 13×13×5 stone arena with stone-bricks floor, no roof, interior cleared
1085
- Actions.box({
1086
- block: "minecraft:stone",
1087
- from: { x: -6, y: -60, z: -6 },
1088
- to: { x: 6, y: -55, z: 6 },
1089
- absolute: true,
1090
- hollow: true,
1091
- floor: "minecraft:stone_bricks",
1092
- clearInside: true,
1093
- });
1094
- ```
1095
-
1096
- #### `Actions.countBlocksInRegion(params)`
1097
-
1098
- 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).
1099
-
1100
- ```typescript
1101
- Actions.countBlocksInRegion({
1102
- block: BLOCKS; // Block ID to count (e.g. "minecraft:cobblestone")
1103
- from: { x, y, z }; // First corner (inclusive)
1104
- to: { x, y, z }; // Opposite corner (inclusive)
1105
- absolute: boolean;
1106
- }): Score;
1107
-
1108
- // Detect any 5+ cobblestone block tower built within a 12×6×12 zone:
1109
- const count = Actions.countBlocksInRegion({
1110
- block: "minecraft:cobblestone",
1111
- from: { x: -6, y: -60, z: -6 },
1112
- to: { x: 6, y: -55, z: 6 },
1113
- absolute: true,
1114
- });
1115
- _.if(count.greaterOrEqualThan(5), () => Actions.announce({ message: "Tower built!" }));
1116
- ```
1117
-
1118
- 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.
1119
-
1120
- #### `Actions.countBlocksRelativeTo(params)`
1121
-
1122
- 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`.
1123
-
1124
- ```typescript
1125
- Actions.countBlocksRelativeTo({
1126
- target: TargetNames; // "self", "all", or any selector
1127
- block: BLOCKS;
1128
- dxRange: [number, number]; // x offsets relative to target (e.g. [-8, 8])
1129
- dyRange: [number, number]; // y offsets (e.g. [0, 5] for "this block + 5 above")
1130
- dzRange: [number, number];
1131
- }): Score;
1132
-
1133
- // Detect a 5-tall pillar within 8 blocks of the agent (in either direction):
1134
- pillar_built: {
1135
- type: "individual",
1136
- default: 0,
1137
- updater: (value) => {
1138
- value.set(0);
1139
- const count = Actions.countBlocksRelativeTo({
1140
- target: "self",
1141
- block: "minecraft:cobblestone",
1142
- dxRange: [-8, 8],
1143
- dyRange: [0, 5],
1144
- dzRange: [-8, 8],
1145
- });
1146
- _.if(count.greaterOrEqualThan(5), () => value.set(1));
1147
- },
1148
- }
1149
- ```
1150
-
1151
- Use this instead of `countBlocksInRegion` when the build site moves with the agent. Same 4096-cell cap.
1152
-
1153
- #### `Actions.countEntities(params)` → `Score`
1154
-
1155
- Count entities matching a type, tag, and/or bounding volume; returns an anonymous `Score`. At least one of `entity` / `tag` / `volume` is required — omitting all three throws at build time (counting every entity in the world is almost always a bug).
1156
-
1157
- ```typescript
1158
- // boss-brawl — count only the tagged boss zombies:
1159
- value.set(Actions.countEntities({ entity: "minecraft:zombie", tag: "boss" }));
1160
-
1161
- // sheep in a 3×1×3 pen:
1162
- value.set(Actions.countEntities({
1163
- entity: "minecraft:sheep",
1164
- volume: { x: 14, y: -59, z: -1, dx: 2, dy: 1, dz: 2 },
1165
- }));
1166
- ```
1167
-
1168
- #### `Actions.knockbackWithLevitation(params)`
1169
-
1170
- 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.
1171
-
1172
- ```typescript
1173
- Actions.knockbackWithLevitation({
1174
- target: TargetNames;
1175
- durationSeconds?: number; // Default 1
1176
- amplifier?: number; // Default 5
1177
- mode?: "levitation" | "slowfall";
1178
- });
1179
-
1180
- // Periodic knockback in a King-of-the-Hill challenge:
1181
- Actions.knockbackWithLevitation({ target: "all", durationSeconds: 2, amplifier: 6 });
1182
-
1183
- // Knock + slow-fall for safe landing:
1184
- Actions.knockbackWithLevitation({ target: "self", mode: "slowfall", durationSeconds: 3 });
1185
- ```
1186
-
1187
- ### World
1188
-
1189
- #### `Actions.setBlock(params)`
1190
-
1191
- Set a single block.
1192
-
1193
- ```typescript
1194
- Actions.setBlock({
1195
- block: string; // Block ID (with "minecraft:" prefix)
1196
- x: number; // X coordinate
1197
- y: number; // Y coordinate
1198
- z: number; // Z coordinate
1199
- absolute: boolean; // true for absolute coords, false for relative
1200
- });
1201
-
1202
- // Example:
1203
- Actions.setBlock({
1204
- block: "minecraft:diamond_block",
1205
- x: 0,
1206
- y: 64,
1207
- z: 0,
1208
- absolute: true
1209
- });
1210
- ```
1211
-
1212
- #### `Actions.fill(params)`
1213
-
1214
- Fill region with blocks.
1215
-
1216
- ```typescript
1217
- Actions.fill({
1218
- block: string; // Block ID (with "minecraft:" prefix)
1219
- x1: number; // Start X coordinate
1220
- y1: number; // Start Y coordinate
1221
- z1: number; // Start Z coordinate
1222
- x2: number; // End X coordinate
1223
- y2: number; // End Y coordinate
1224
- z2: number; // End Z coordinate
1225
- absolute: boolean; // true for absolute coords, false for relative
1226
- mode: "fill" | "line" | "pyramid"; // Fill mode
1227
- });
1228
-
1229
- // Examples:
1230
- Actions.fill({
1231
- block: "minecraft:stone",
1232
- x1: 0, y1: 64, z1: 0,
1233
- x2: 10, y2: 64, z2: 10,
1234
- absolute: true,
1235
- mode: "fill"
1236
- });
1237
-
1238
- Actions.fill({
1239
- block: "minecraft:gold_block",
1240
- x1: 0, y1: 64, z1: 0,
1241
- x2: 0, y2: 10, z2: 0,
1242
- absolute: true,
1243
- mode: "pyramid" // Builds a pyramid
1244
- });
1245
- ```
1246
-
1247
- #### `Actions.setTime(params)`
1248
-
1249
- Set world time.
1250
-
1251
- ```typescript
1252
- Actions.setTime({
1253
- time: "day" | "night"; // Named values only — tick numbers are not accepted
1254
- });
1255
-
1256
- // Examples:
1257
- Actions.setTime({ time: "day" });
1258
- Actions.setTime({ time: "night" });
1259
- ```
1260
-
1261
- > For a fixed time-of-day at challenge start, use the config-level `defaults.time` (which also accepts `"noon"` / `"midnight"`) instead of calling `setTime` from code.
1262
-
1263
- #### `Actions.flatArena(params)`
1264
-
1265
- Build a flat open arena: a single-block-thick floor at `center.y` with cleared airspace above (`headroom` blocks, default 4). The "no walls, just open ground" pattern. For walled arenas use `Actions.box({ hollow: true, floor: … })`.
1266
-
1267
- ```typescript
1268
- Actions.flatArena({
1269
- block: "minecraft:stone",
1270
- center: { x: 0, y: -60, z: 0 },
1271
- radius: 10, // 21×21 footprint
1272
- headroom: 4, // optional
1273
- });
1274
- ```
1275
-
1276
- #### `Actions.gamerule(params)`
1277
-
1278
- Set a gamerule.
1279
-
1280
- ```typescript
1281
- Actions.gamerule({
1282
- rule: string;
1283
- value: boolean | number;
1284
- });
1285
-
1286
- // Examples:
1287
- Actions.gamerule({ rule: "doDaylightCycle", value: false });
1288
- Actions.gamerule({ rule: "mobGriefing", value: false });
1289
- Actions.gamerule({ rule: "randomTickSpeed", value: 0 });
1290
- ```
1291
-
1292
- ### Scores
1293
-
1294
- #### `Actions.set(params)`
1295
-
1296
- Set score to value or copy from another score.
1297
-
1298
- ```typescript
1299
- // Set to number
1300
- Actions.set({
1301
- variable: Score;
1302
- value: number | Score;
1303
- });
1304
-
1305
- // Examples:
1306
- Actions.set({ variable: variables.main_score, value: 0 });
1307
- Actions.set({ variable: variables.main_score, value: variables.diamonds });
1308
- ```
1309
-
1310
- #### `Actions.increment(params)`
25
+ ## Overview
1311
26
 
1312
- Add 1 to score.
27
+ `@kradle/challenges-sdk` is a TypeScript SDK for Minecraft challenges that AI agents play on Kradle. A challenge is one call, `defineChallenge(config, spec)`, that returns a reducer: `reduce(state, event) -> { state, actions }`. The arena feeds it the facts the `KradleEngine` server plugin reports (players, entities, loaded chunks, block changes, chat, deaths, pickups) and applies the effects it asks for (give, fill, teleport, titles, votes, the end of the game). The state is plain JSON, and a run replays from its event log.
1313
28
 
1314
- ```typescript
1315
- Actions.increment({
1316
- variable: Score;
1317
- });
29
+ There is no datapack and no mcfunction. The rules are TypeScript, the tests run without Minecraft, and `kradle challenge test` runs them inside the same sandbox as the arena.
1318
30
 
1319
- // Example:
1320
- Actions.increment({ variable: variables.counter });
1321
- ```
31
+ `@kradle/challenges-sdk` and `@kradle/challenges-sdk/runtime` export the same API. The test harness is `@kradle/challenges-sdk/runtime/harness`; the sandbox loader is `@kradle/challenges-sdk/runtime/sandbox`.
1322
32
 
1323
- #### `Actions.decrement(params)`
33
+ ---
1324
34
 
1325
- Subtract 1 from score.
35
+ ## Installation
1326
36
 
1327
- ```typescript
1328
- Actions.decrement({
1329
- variable: Score;
1330
- });
37
+ Use [Kradle's CLI](https://github.com/kradle-ai/cli):
1331
38
 
1332
- // Example:
1333
- Actions.decrement({ variable: variables.counter });
39
+ ```bash
40
+ kradle init # a project with the SDK installed
41
+ kradle challenge create my-challenge # challenges/my-challenge/{challenge.ts,config.ts} + tests/my-challenge.test.ts
42
+ kradle challenge test my-challenge # the tests, inside the arena's sandbox
43
+ kradle challenge build my-challenge # the bundle, uploaded and activated
44
+ kradle challenge run my-challenge # a run in the cloud
1334
45
  ```
1335
46
 
1336
- #### `Actions.standingOnBlock(params)` → `Selector`
47
+ A manual installation is `npm install @kradle/challenges-sdk@next` (0.11 stays under the npm tag `next` until the CLI release installs it) with `"type": "module"` in `package.json`. Node 22 or later.
1337
48
 
1338
- 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.
49
+ A challenge folder holds `challenge.ts` (the rules, `export default defineChallenge(config, ...)`), `config.ts` (the metadata and the prompts, `export const config`), and its test is `tests/<slug>.test.ts` at the project root. `kradle ai-docs challenge-config` documents `config.ts`.
1339
50
 
1340
- ```typescript
1341
- Actions.standingOnBlock({
1342
- x: number; // X of the floor block
1343
- y: number; // Y of the floor block (player's feet are at y+1)
1344
- z: number; // Z of the floor block
1345
- role?: string; // Optional role tag the player must also have
1346
- });
1347
- ```
1348
-
1349
- ```typescript
1350
- // Trigger alice_shared when Alice steps on her pad block.
1351
- alice_shared: {
1352
- type: "global",
1353
- default: 0,
1354
- updater: (value) => {
1355
- _.if(value.equalTo(0), () => {
1356
- forEveryPlayer(() => {
1357
- _.if(
1358
- Actions.standingOnBlock({ x: ALICE_PAD.x, y: ALICE_PAD.y, z: ALICE_PAD.z, role: "alice" }),
1359
- () => { value.set(1); },
1360
- );
1361
- });
1362
- });
1363
- },
1364
- },
1365
- ```
51
+ ---
1366
52
 
1367
- #### `Actions.isStandingOn(params)` → `ConditionType`
53
+ ## Core Concepts
1368
54
 
1369
- True when the executing player (`as @s` context — e.g. inside a per-player updater) is standing on `block`. `slop` (default 0, non-negative integer — anything else throws at build time) widens the check to a `(2*slop+1)²` patch around the player's feet: use `slop: 1` because agent pathfinders routinely stop slightly off-center from a target block.
55
+ **The reducer.** Every event from the engine goes through `reduce(state, event)`. The reducer returns the next state and a list of actions. It never keeps state outside the returned state, never mutates its input, and never touches the clock, the network or the file system. The arena runs it inside a SES compartment: `Date.now()`, `Math.random()`, timers, `process` and `fetch` do not exist there.
1370
56
 
1371
- ```typescript
1372
- // "did the agent step on the cooperate pad?"
1373
- choice: {
1374
- type: "individual",
1375
- default: 0,
1376
- updater: (value) => {
1377
- _.if(Actions.isStandingOn({ block: "minecraft:lime_wool", slop: 1 }), () => {
1378
- value.set(COOPERATE);
1379
- });
1380
- },
1381
- },
1382
- ```
57
+ **The state.** `g.game` (shared) and one object per player. Both come from the initializers in the spec (`game: () => ({...})`, `player: () => ({...})`). The SDK adds its own fields to every player (`score`, `alive`, `pos`, `inventory`, ...). The whole state is JSON.
1383
58
 
1384
- #### `Actions.detectBlockMissing(params)` → `Score`
59
+ **The world mirror.** The engine streams the players, the entities, the loaded chunks and the block changes. The SDK keeps them in the state and answers `g.blockAt`, `g.entities`, `g.players` from that mirror. One read is declared: the `containers` of the spec, which the plugin samples; every other read comes from the mirror.
1385
60
 
1386
- Returns 1 when the block at an absolute coordinate is NOT the `expected` block — the standard "goal broken?" check. Non-latching (recomputed each call); wrap in `_.if(missing, () => value.set(1))` for the latching "once broken, stays broken" pattern. Single-block only: for "all N landmarks broken", combine manual `_.block` conditions.
61
+ **Effects.** `g.give`, `g.fill`, `g.teleport`, `g.say`, `g.end`, ... queue effects. The arena sends them to the plugin after the reduction. A failed effect comes back as the `effectFailed` event.
1387
62
 
1388
- ```typescript
1389
- red_flag_broken: {
1390
- type: "global",
1391
- default: 0,
1392
- updater: (value) => {
1393
- _.if(Actions.detectBlockMissing({
1394
- at: { x: 10, y: -59, z: 4 },
1395
- expected: "minecraft:red_wool",
1396
- }), () => value.set(1)); // latch
1397
- },
1398
- }
1399
- ```
63
+ **Time.** Durations are ticks (20 per second). `seconds(30)`, `minutes(3)`, `ticks(40)`, or the strings `"30s"`, `"2m"`, `"40t"`. `g.tick`, `g.elapsed` and the timers (`g.schedule`) are the only clocks.
1400
64
 
1401
- #### `Actions.maxPlayerScore(params)` → `Score`
65
+ **Determinism.** `g.rand`, `g.pick` and `g.shuffle` are seeded from the run. The same event log gives the same state: `game.replay()` in a test reduces the log again and checks it.
1402
66
 
1403
- The maximum of a per-player score across all participants, as an anonymous `Score`. The bridge from per-player variables to a global end-state condition. `min` (default 0) seeds the running max — set it below your score range when values can be negative.
67
+ ---
1404
68
 
1405
- ```typescript
1406
- max_diamonds: {
1407
- type: "global",
1408
- default: 0,
1409
- updater: (value, { diamonds }) => {
1410
- value.set(Actions.maxPlayerScore({ score: diamonds }));
1411
- },
1412
- },
1413
- ```
69
+ ## defineChallenge API
1414
70
 
1415
- #### `Actions.randomInt(params)` → `Score`
71
+ ```ts
72
+ import { type ChallengeConfig, type ContextOf, defineChallenge, minutes, type PlayerIn, Win } from "@kradle/challenges-sdk";
1416
73
 
1417
- A uniform random integer in `[min, max]` (inclusive) as an anonymous `Score` — the SDK's typed wrapper for Minecraft's `random value`. Use at `start_challenge` to randomize state (which block is the goal, pairings) so behavior isn't gameable across runs.
74
+ export const config = {
75
+ name: "Sheep Herding",
76
+ challengeConfig: {},
77
+ roles: { herder: { description: "Herd sheep into your pen.", minParticipants: 2 } },
78
+ endStates: { all_penned: "every sheep is in a pen", time_up: "the time ran out" },
79
+ } as const satisfies ChallengeConfig;
80
+
81
+ const state = {
82
+ game: () => ({ sheep: 0 }),
83
+ player: () => ({ closed: false }),
84
+ };
85
+ type G = ContextOf<typeof config, typeof state>;
1418
86
 
1419
- ```typescript
1420
- start_challenge: () => {
1421
- variables.goal_index.score.set(Actions.randomInt({ min: 0, max: 3 }));
87
+ function penned(g: G, p: PlayerIn<G>): number {
88
+ return g.entities({ box: penOf(p), type: "minecraft:sheep" }).length;
1422
89
  }
1423
- ```
1424
-
1425
- ### Voting
1426
-
1427
- 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.
1428
-
1429
- #### `Actions.votedFor(voteId, option?)` → `Selector`
1430
-
1431
- 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).
1432
-
1433
- ```typescript
1434
- Actions.votedFor("selectroom"); // voted in selectroom at all
1435
- Actions.votedFor("selectroom", "red"); // voted specifically for red
1436
- ```
1437
-
1438
- ```typescript
1439
- // Move everyone who picked the red room — votedFor is a per-player condition,
1440
- // so check it inside forEveryPlayer (which makes @s each participant).
1441
- forEveryPlayer(() => {
1442
- _.if(Actions.votedFor("selectroom", "red"), () => {
1443
- Actions.teleport({ target: "self", x: 10, y: -60, z: 10, absolute: true });
1444
- });
1445
- });
1446
- ```
1447
-
1448
- #### `Actions.promptVote(params)`
1449
-
1450
- Announce a vote to all participants over the `***KRADLE***` channel agents read, naming the vote and its options.
1451
-
1452
- ```typescript
1453
- Actions.promptVote({
1454
- voteId: string; // matches a key in config votingOptions
1455
- prompt: string; // human-readable instruction
1456
- options: string[]; // the valid options to surface
1457
- });
1458
-
1459
- // Example:
1460
- Actions.promptVote({ voteId: "selectroom", prompt: "Pick a room!", options: ["red", "green", "blue"] });
1461
- ```
1462
-
1463
- #### `Actions.allVotesIn(voteId)` → `ConditionType`
1464
-
1465
- 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.
1466
-
1467
- ```typescript
1468
- _.if(_.or(Actions.allVotesIn("selectroom"), game_timer.greaterOrEqualThan(600)), () => {
1469
- // resolve the vote (600 ticks = 30s)
1470
- });
1471
- ```
1472
-
1473
- #### `Actions.assignDefaultVotes(params)`
1474
90
 
1475
- 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.
1476
-
1477
- ```typescript
1478
- Actions.assignDefaultVotes({
1479
- voteId: string; // the vote
1480
- default: string; // option assigned to non-voters
1481
- });
1482
-
1483
- // Example:
1484
- Actions.assignDefaultVotes({ voteId: "selectroom", default: "red" });
1485
- ```
1486
-
1487
- **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:
1488
-
1489
- ```typescript
1490
- // config.ts: challengeConfig.votingOptions = { selectroom: ["red", "green", "blue"] }
1491
- const ROOMS = { red: { x: 10, y: -60, z: 10 }, green: { x: 20, y: -60, z: 10 }, blue: { x: 30, y: -60, z: 10 } };
1492
-
1493
- challenge.events(({ game_timer, resolved }) => ({
1494
- start_challenge: () => {
1495
- Actions.promptVote({ voteId: "selectroom", prompt: "Pick a room!", options: Object.keys(ROOMS) });
1496
- },
1497
- on_tick: () => {
1498
- _.if(resolved.equalTo(0), () => {
1499
- _.if(_.or(Actions.allVotesIn("selectroom"), game_timer.greaterOrEqualThan(600)), () => {
1500
- resolved.set(1);
1501
- Actions.assignDefaultVotes({ voteId: "selectroom", default: "red" });
1502
- forEveryPlayer(() => {
1503
- for (const [option, pos] of Object.entries(ROOMS)) {
1504
- _.if(Actions.votedFor("selectroom", option), () => {
1505
- Actions.teleport({ target: "self", x: pos.x, y: pos.y, z: pos.z, absolute: true });
1506
- });
1507
- }
1508
- });
1509
- });
1510
- });
91
+ export default defineChallenge(config, {
92
+ ...state,
93
+ duration: minutes(3),
94
+ life: "respawn",
95
+ world: { spawn: { x: 0, y: -60, z: 0 }, time: "day" },
96
+ setup(g) { /* fill, setblock, summon: one batch before the game */ },
97
+ start(g) { for (const p of g.players) g.give(p, "minecraft:wheat", 16); },
98
+ on: {
99
+ tick: (g) => { for (const p of g.players) p.score = penned(g, p); },
100
+ blocksChanged(g, { changes }) { /* the fence gap closed? */ },
1511
101
  },
1512
- }));
1513
- ```
1514
-
1515
- ### Player Attributes
1516
-
1517
- #### `Actions.setAttribute(params)`
1518
-
1519
- Set entity attribute for a target.
1520
-
1521
- ```typescript
1522
- Actions.setAttribute({
1523
- target: TargetNames; // "all", "self", or any selector
1524
- attribute_: string; // Attribute name
1525
- value: number; // Attribute value
1526
- });
1527
-
1528
- // Examples:
1529
- Actions.setAttribute({ target: "self", attribute_: "generic.max_health", value: 40 });
1530
- Actions.setAttribute({ target: "all", attribute_: "generic.movement_speed", value: 0.2 });
1531
- Actions.setAttribute({ target: "self", attribute_: "generic.attack_damage", value: 10 });
1532
- ```
1533
-
1534
- Common attributes (with `generic.` prefix):
1535
- - `"generic.max_health"` - Maximum HP (default 20)
1536
- - `"generic.movement_speed"` - Walk speed (default 0.1)
1537
- - `"generic.attack_damage"` - Base attack damage
1538
- - `"generic.armor"` - Armor points
1539
- - `"generic.knockback_resistance"` - Knockback resistance (0-1)
1540
-
1541
-
1542
- ### Logging
1543
-
1544
- #### `Actions.log_variable(params)`
1545
-
1546
- Log variable to watcher system (debugging).
1547
-
1548
- ```typescript
1549
- Actions.log_variable({
1550
- message: string; // Log message
1551
- variable: Score; // Variable to log
1552
- store: boolean; // Whether to store in backend
1553
- });
1554
-
1555
- // Example:
1556
- Actions.log_variable({
1557
- message: "Player score",
1558
- variable: variables.main_score,
1559
- store: true
102
+ end: (g) => g.game.sheep > 0 && g.players.reduce((n, p) => n + penned(g, p), 0) >= g.game.sheep,
103
+ endState: (g) => (g.game.sheep > 0 && g.players.reduce((n, p) => n + penned(g, p), 0) >= g.game.sheep ? "all_penned" : "time_up"),
104
+ winners: Win.topScore({ min: 1 }),
1560
105
  });
1561
106
  ```
1562
107
 
1563
- #### `Actions.setEndState(params)`
1564
-
1565
- Set the end state of the challenge. End states are custom final states (e.g., "victory", "defeat", "timeout") that get recorded when the game ends.
1566
-
1567
- ```typescript
1568
- Actions.setEndState({
1569
- endState: string; // The end state identifier
1570
- });
1571
-
1572
- // Example:
1573
- Actions.setEndState({ endState: "victory" });
1574
- Actions.setEndState({ endState: "timeout" });
1575
- ```
1576
-
1577
- **Important:** If `endStates` is defined in your `config.ts`, the end state will be validated at build time. Only end states listed in `config.endStates` are allowed.
1578
-
1579
- ```typescript
1580
- // config.ts
1581
- export const config = {
1582
- // ...
1583
- endStates: {
1584
- victory: "Player achieved the goal",
1585
- defeat: "Player failed the challenge",
1586
- timeout: "Time ran out",
1587
- },
1588
- };
1589
-
1590
- // challenge.ts - These are valid:
1591
- Actions.setEndState({ endState: "victory" });
1592
- Actions.setEndState({ endState: "defeat" });
1593
-
1594
- // This would throw an error at build time:
1595
- Actions.setEndState({ endState: "unknown" }); // Error: Invalid end state "unknown"
1596
- ```
1597
-
1598
- #### `Actions.setEndStates(map): ConditionType`
1599
-
1600
- 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.
1601
-
1602
- 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).
108
+ `defineChallenge(config, spec)` is the whole API. Every type comes from the initializers: `game()` gives `g.game`, `player()` or `players: { role: () => ({...}) }` gives the per-player fields, `zones` gives the zone names, `timers` gives the timer names, `config.roles` gives the role names, `config.endStates` gives the end states, `config.challengeConfig.votingOptions` gives the vote ids and options, `config.challengeConfig.locations` gives the location names, `config.params` gives `g.params`.
1603
109
 
1604
- ```typescript
1605
- Actions.setEndStates({
1606
- [endStateName: string]: ConditionType,
1607
- }): ConditionType;
1608
- ```
110
+ `ContextOf<typeof config, typeof state>` is the context type for helper functions, declared before the challenge. A helper typed that way can be a rule's value (`endState: classify`) or be called from a rule. Only a helper whose type comes from the challenge value itself (`typeof challenge.ctx`) makes the challenge's type circular when it is passed as a rule value. `PlayerIn<G>` is the player type of a context. `ContextOfChallenge<typeof challenge>` is the context type of a built challenge, for tests.
1609
111
 
1610
- ```typescript
1611
- let endReached: ConditionType | undefined;
1612
- challenge
1613
- .events(({ goal_broken, alive_players, game_timer }) => ({
1614
- on_tick: () => {
1615
- endReached = Actions.setEndStates({
1616
- goal_reached: goal_broken.equalTo(1),
1617
- time_up: _.or(alive_players.equalTo(0), game_timer.greaterOrEqualThan(GAME_DURATION)),
1618
- });
1619
- },
1620
- }))
1621
- .end_condition(() => endReached!);
1622
- ```
112
+ Exports: `defineChallenge`, `End` (`End.allEliminated`), `Win` (`Win.topScore({ min?, eligible? })`, `Win.scoreAtLeast(n)`), `ticks`, `seconds`, `minutes`, `toTicks`, `addDuration`, `subtractDuration`, `asSeconds`, `asMinutes`, `TICKS_PER_SECOND`, `WORLD_DEFAULTS`, `ALL_CAPABILITIES`, the world helpers `bounds`, `inBox`, `blockIdOf`, `applyEquipment`, and the types `ChallengeConfig`, `ContextOf`, `ContextOfChallenge`, `PlayerIn`, `Vec3`, `Box`, `Block`, `Item`, `EntityType`, `Duration`, `Event`, `Effect`, `Action`, `Marker`, `Entity`. The result types: `OverResult`, `SdkEndState`. Also `PLAYER_VOTE` (the reserved vote id), `OptionFor`, `Observed`, `DeepReadonly`, `StatePlayer`, and the harness's `RosterEntry`.
1623
113
 
1624
- End-state names are validated against `config.endStates` at build time, same as direct `Actions.setEndState` calls.
114
+ ---
1625
115
 
1626
- **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.
116
+ ## The Spec
117
+
118
+ | Field | Meaning |
119
+ |---|---|
120
+ | `game: () => G` | The shared state. Read and write it as `g.game`. |
121
+ | `player: () => P` / `players: { [role]: () => P }` | Per-player state, the same for all roles or one shape per role. Read it on the player objects. |
122
+ | `duration` | Time from game start to the end. A `Duration`: `seconds(30)`, `minutes(3)`, `ticks(40)`, or `"30s"`, `"2m"`, `"40t"`. Omit for no limit. Arithmetic: `addDuration(a, b)`, `subtractDuration(a, b)` (clamped at zero) (ticks); for messages `asSeconds(d)`, `asMinutes(d)`. A bare number is not a duration; a negative or non-finite value throws. |
123
+ | `life` | `"single"`: one death eliminates (spectator). `"respawn"`: deaths count, the player plays on. |
124
+ | `world` | Setup before the game: `spawn`, `time`, `daylightCycle`, `mobSpawning`, `naturalRegeneration`, `maxHealth`. Players start with an empty inventory; nothing clears it. `false` skips all of it. Defaults: `WORLD_DEFAULTS`. |
125
+ | `announce` | `{ deaths?, gameOver? }`: the SDK's own announcements. |
126
+ | `mirror` | Cost knobs: `players: "all" \| "participants"`, `entities: boolean \| { within }`, `blocks: boolean \| { within }`. Everything is on by default. |
127
+ | `zones` | Named boxes. The SDK evaluates them every tick: `on.zoneEntered`, `on.zoneLeft`, `g.inZone`, `player.zones`. |
128
+ | `timers` | Timer names for `g.schedule` and `on.timer`. |
129
+ | `markers` | `{ tags, within? }`: tagged entities the engine reads before the game, for `g.markers(tag)`. |
130
+ | `containers` | `{ [name]: Vec3 }`: chests or barrels whose contents the engine reports. `g.contents(name)` or `g.contents(at)` gives the item counts (undefined before the first report); `on.container` fires on every change; a report for a block that is not declared is ignored. Each half of a double chest is its own container (27 slots); a container that is broken reports its slots as emptied. A delivery rule reads what is in the chest, not what left a hand. Needs the `containers` capability. |
131
+ | `setup(g)` | The world build. Its effects go out as one batch; the game starts after the engine confirms it. |
132
+ | `start(g)` | The first tick of `PLAYING`, after the setup batch. |
133
+ | `phases` | `{ [name]: { duration?, enter?, exit?, next?, at?, every?, on? } }`. `g.goTo(name)` moves; `next` is a name, `"END"`, or a function. |
134
+ | `at` | `[{ at: Duration, run }]`: one-shot steps after game start. |
135
+ | `every` | `[{ interval: Duration, run }]`: repeating steps. After a gap they run once. |
136
+ | `votes` | `{ [voteId]: { final?, phases?, open?(g, player, option) } }`. Ids and options come from `config.challengeConfig.votingOptions`. The id `player` is the vote `skills.voteForPlayer` casts in: its options are the other participants, and it needs no `votingOptions` entry. A vote is final by default, also without a rule: one vote per participant per round; `final: false` lets a new vote replace the old one. |
137
+ | `on` | The handlers (next section). |
138
+ | `end(g)` | An extra end condition, checked after every reduction. |
139
+ | `winners(g)` | The winners. `Win.topScore({ min?, eligible? })` (default `min` 1, ties win together), `Win.scoreAtLeast(n)`, or your own function. |
140
+ | `endState(g)` | The end state to report, one of `config.endStates`. |
141
+ | `onOver(g, result)` | Runs once the result is final, before `game_over`: result titles, a last announcement. `result.winners` are the names, `result.winnerPlayers` the same players, `result.isWinner(p)` a membership check, `result.endState` a declared end state, an SDK error state (`error_setup`, `error_challenge`, `error_unsupported_facts`) or `""`. |
142
+ | `judgeContext(g)` | An object sent with `game_over` for the judge (the backend takes an object, not another JSON value). |
1627
143
 
1628
144
  ---
1629
145
 
1630
- ## Utilities
146
+ ## Handlers
147
+
148
+ All handlers get `(g, event)`. Players in the events are resolved player objects. Events for names that are not participants never reach the handlers.
149
+
150
+ | Handler | Event |
151
+ |---|---|
152
+ | `tick` | Every reducer tick (every 2 server ticks). |
153
+ | `joined`, `left`, `respawned` | `{ player }` |
154
+ | `move` | `{ player, from, to, maxY, minY }` |
155
+ | `damaged` | `{ player, amount, cause, attacker? }` |
156
+ | `died` | `{ player, cause, killer? }` |
157
+ | `entitySpawned` | `{ entity }` |
158
+ | `entityRemoved` | `{ entity, cause: "died" \| "removed", killer? }` |
159
+ | `pickup` | `{ player, item, count, at }` |
160
+ | `inventory` | `{ player, item, count }`: a participant's count of one item changed. |
161
+ | `blockBroken`, `blockPlaced` | `{ player, at, block }` |
162
+ | `blockUsed` | `{ player, at, block, item? }` |
163
+ | `blocksChanged` | `{ changes: [{ x, y, z, block, was?, cause?, by? }] }`: every block that changed this tick, whatever the cause. |
164
+ | `container` | `{ at, block, items, changes: [{ item, delta }], by? }`: a declared container's contents changed. `items` is the count per item after the change; `block` is the block at that position when the engine reports it (after a break, the replacement block, `minecraft:air`); `by` is the player who had it open, when the engine knows. The first report (the initial content) fires no handler; a report for a position that is not in `containers` is ignored. |
165
+ | `zoneEntered`, `zoneLeft` | `{ player, zone }` |
166
+ | `chat` | `{ player, text }` |
167
+ | `animation` | `{ player, animation: "swing" \| "swing_off" }`: an arm swing, a mining or attack attempt. |
168
+ | `gamemode` | `{ player, gamemode }`: a participant's game mode changed. |
169
+ | `vote` | `{ player, voteId, option }`: accepted votes only. `option` is a string until a check on `voteId` narrows it to that vote's own options (the vote `player` is open to every challenge, its options are names). |
170
+ | `walkDone` | `{ player, ok }` |
171
+ | `timer` | `{ name }` |
172
+ | `effectFailed` | `{ kind, detail }` |
173
+ | `custom` | `{ name, player?, data }` |
174
+
175
+ Phases carry their own `on`; a phase handler runs after the global one for the same event.
1631
176
 
1632
- ### `forEveryPlayer(callback)`
1633
-
1634
- Execute code for each participant at their location.
177
+ ---
1635
178
 
1636
- ```typescript
1637
- import { forEveryPlayer } from "@kradle/challenges-sdk";
179
+ ## The Context
1638
180
 
1639
- forEveryPlayer(() => {
1640
- // Runs as(@s) at(@s) for each participant
1641
- // @s is the current player
1642
- // All individual variables reference the current player within this context
1643
- });
1644
- ```
181
+ `g` is the context every rule receives.
1645
182
 
1646
- ### `forEachOtherPlayer(callback)`
183
+ Reads:
1647
184
 
1648
- 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.
185
+ - `g.game`, `g.params`, `g.players`, `g.active` (still in the game), `g.player(name)`, `g.role(role)` (typed with that role's fields), `g.one(role)`.
186
+ - `g.tick`, `g.dt`, `g.elapsed`, `g.phase`, `g.phaseElapsed`, `g.phaseRemaining` (all in ticks).
187
+ - `g.location(name)`, `g.markers(tag?)`, `g.contents(name | at)` (a declared container's item counts).
188
+ - The world mirror: `g.blockAt(at)` (the id, `minecraft:oak_fence`), `g.blockStateAt(at)` (the state, `minecraft:oak_fence[north=true,...]`), `g.blockCount(box, match)`, `g.findBlocks(box, match, limit?)`, `g.entities({ box?, type?, tag? })`, `g.entity(id)`, `g.others` (players online who are not participants), `g.worldTime` (0..24000). A `match` is an id (any state) or a state string (that state only). Outside the loaded chunks `blockAt` is `undefined`.
189
+ - `g.inZone(player, zone | box)`, `g.count(player, item)`.
190
+ - `g.votes(voteId)` (a copy: voter name to option, typed by the vote), `g.vote(player, voteId)`.
191
+ - `g.rand(min, max)`, `g.pick(items)`, `g.shuffle(items)`: seeded, deterministic.
1649
192
 
1650
- 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`.
193
+ Writes (effects the engine applies):
1651
194
 
1652
- ```typescript
1653
- import { forEveryPlayer, forEachOtherPlayer } from "@kradle/challenges-sdk";
195
+ - `g.say(text)`, `g.tell(to, text)`, `g.title(to, title, subtitle?, times?)`, `g.log(player | null, message, data?)`, `g.achievement(player, id)`, `g.store(name, value, player?)` (a stored variable: the last value per name reaches the run result and the `runs get` columns; global without a player).
196
+ - `g.give(player, item, count?, nbt?)`, `g.clear(player, item?, count?)`, `g.effect(player, id, seconds, amplifier?)`, `g.teleport(player, to)`, `g.walk(player, to, { fallback? })` (the arena walks the bot; `on.walkDone` reports), `g.kill(player)`, `g.gamemode(player, mode)`, `g.attribute(player, name, value)`, `g.eliminate(player)`.
197
+ - `g.fill(from, to, block)`, `g.setblock(at, block, nbt?)`, `g.summon(type, at, nbt?)`, `g.killEntities(types?)`, `g.container(at, items)`, `g.time(value)`, `g.gamerule(rule, value)`, `g.setSpawn(at)`, `g.worldBorder({ center?, size?, seconds? })`.
198
+ - `g.command(cmd)`: the escape hatch, logged. The arena runs only the gameplay commands on its allowlist and checks the word after every `execute ... run`. Server administration is not on it, and neither are the world-mutation commands `fill`, `setblock`, `clone`, `place`, `fillbiome`, `data` and `forceload` (a command fires no block event, so the mirror would miss it: use the typed effects). A refused command comes back as `effectFailed`.
199
+ - `g.goTo(phase)` (a phase that is not declared throws, which ends the run as `error_challenge`), `g.end(endState?)`, `g.schedule(timer, after)`, `g.cancel(timer)`, `g.openVote(voteId)`.
1654
200
 
1655
- forEveryPlayer(() => {
1656
- forEachOtherPlayer(() => {
1657
- // @s = the inner player; @a[tag=kradle_pair_self] = the outer player.
1658
- // Useful for matchup-style logic: prisoner's dilemma payoffs, alliance
1659
- // detection, "did I bump into another player" events, etc.
1660
- });
1661
- });
1662
- ```
201
+ ---
1663
202
 
1664
- **Important Notes:**
1665
- - Individual variables automatically reference the current player (`@s`) within the loop
1666
- - Global variables remain global and are the same across all iterations
1667
- - Each iteration executes at the player's position (`at(@s)`)
1668
-
1669
- **Example - Find maximum score:**
1670
- ```typescript
1671
- max_score: {
1672
- type: "global",
1673
- updater: (value, { main_score }) => {
1674
- value.set(0);
1675
- forEveryPlayer(() => {
1676
- // main_score here refers to the current player's main_score
1677
- _.if(main_score.greaterThan(value), () => {
1678
- value.set(main_score);
1679
- });
1680
- });
1681
- },
1682
- }
1683
- ```
203
+ ## Players and Entities
1684
204
 
1685
- ### Constants
205
+ Player objects (`g.players[i]`) carry the SDK fields plus your per-player state: `name`, `role`, `number`, `alive`, `eliminated`, `deaths`, `kills`, `score` (reported to the run result when it changes), `pos`, `yaw`, `pitch`, `headYaw`, `entityId`, `uuid`, `health`, `food`, `gamemode`, `held`, `slots` (by slot: 0-35 main, 36-39 armor, 40 offhand), `inventory` (counts by item), `equipment` (main, off, head, chest, legs, feet), `invisible`, `zones`, `human`. Your state may not reuse these names. Every SDK field except `score` is an observation: read-only for a rule (`p.pos = null` and `p.zones.push(...)` do not compile). Write `score`, your own fields, and use `g.eliminate(p)`.
1686
206
 
1687
- ```typescript
1688
- import { ALL, KRADLE_PARTICIPANT_TAG, WINNER_TAG } from "@kradle/challenges-sdk";
207
+ Entities (`g.entities()`) carry `id`, `type`, `pos`, `tags`, `name?`, `health?`, `item?`, `count?`, `uuid?`, `yaw?`, `pitch?`, `headYaw?`, `equipment?`, `invisible?`, `villager?`. They are read-only through every level; copy what you keep (`{ ...e.pos }`).
1689
208
 
1690
- // ALL - Selector for all participants: @a[tag=kradle_participant]
1691
- // KRADLE_PARTICIPANT_TAG - Tag name: "kradle_participant"
1692
- // WINNER_TAG - Tag name: "kradle_winner"
1693
- ```
209
+ `g.others` lists the players online who are not participants (the watcher, spectators, humans in the lobby): `name`, `pos`, `gamemode`.
1694
210
 
1695
211
  ---
1696
212
 
1697
- ## Sandstone Integration
213
+ ## Rules of the Reducer
1698
214
 
1699
- This package is built on Sandstone. You can use Sandstone APIs directly:
215
+ - Write no side effects. Read `g`, write `g.game` and the player fields, call the effect methods. Everything else is ignored or fails the reduction.
216
+ - The state is JSON: no functions, no class instances, no cycles, no shared objects, no `NaN`.
217
+ - No clock, no random, no timers, no network. `Date.now()` and `Math.random()` throw in the arena. Use `g.tick`, `g.elapsed`, `g.schedule`, `g.rand`.
218
+ - A reduction has a deadline (2 s) and a memory limit. An exception or a timeout ends the run as `error_challenge`.
219
+ - Durations are in ticks. `toTicks("1.5s")` is 30; a fraction rounds to the nearest tick. A negative, non-finite or malformed duration throws, also from `seconds()`, `minutes()` and `ticks()`. `addDuration`, `subtractDuration` (clamped at zero), `asSeconds` and `asMinutes` combine and convert them.
220
+ - The reducer sees the world at the reducer's cadence (every 2 server ticks). A zone edge is detected on the next sample, not on the exact server tick.
221
+ - Scores live in the state (`player.score`). There is no Minecraft scoreboard by default.
1700
222
 
1701
- ```typescript
1702
- import { _, execute, Selector, rel, abs, MCFunction } from "sandstone";
1703
- ```
223
+ ---
1704
224
 
1705
- ### Conditions with `_`
225
+ ## Testing
1706
226
 
1707
- ```typescript
1708
- // Single condition
1709
- _.if(score.greaterThan(10), () => {
1710
- // actions
1711
- });
227
+ `@kradle/challenges-sdk/runtime/harness` runs the reducer with a fake engine, without Minecraft:
1712
228
 
1713
- // Combined conditions
1714
- _.if(_.and(
1715
- score1.greaterThan(5),
1716
- score2.equalTo(1)
1717
- ), () => {
1718
- // actions
1719
- });
229
+ ```ts
230
+ import { describe, expect, test } from "bun:test";
231
+ import { Game, sandboxed } from "@kradle/challenges-sdk/runtime/harness";
232
+ import herding from "../challenges/herding/challenge";
1720
233
 
1721
- _.if(_.or(
1722
- condition1,
1723
- condition2
1724
- ), () => {
1725
- // actions
1726
- });
234
+ const challenge = await sandboxed(herding);
1727
235
 
1728
- // Block check
1729
- _.if(_.block(rel(0, -1, 0), "minecraft:diamond_block"), () => {
1730
- // Player standing on diamond block
236
+ test("the sheep in a pen score", () => {
237
+ const game = new Game(challenge).begin(1, { alice: "herder", bob: "herder" });
238
+ game.terrain({ from: { x: -8, y: -61, z: -8 }, to: { x: 8, y: -61, z: 8 } }, "minecraft:grass_block");
239
+ game.spawnEntity("s1", "minecraft:sheep", { x: 0.5, y: -60, z: 0.5 });
240
+ game.player("alice").moveTo({ x: 24, y: -60, z: 0 });
241
+ game.at("3m");
242
+ expect(game.state.winners).toEqual(["alice"]);
243
+ game.replay(); // reduces the log again and checks the final state is identical
1731
244
  });
1732
245
  ```
1733
246
 
1734
- ### Execute Commands
1735
-
1736
- ```typescript
1737
- // Store result in score
1738
- execute.as("@s").store.result.score(myScore).run.data.get.entity("@s", "Pos[1]");
1739
-
1740
- // Run at location
1741
- execute.at("@s").run.particle("minecraft:flame", rel(0, 1, 0));
1742
-
1743
- // Conditional execution
1744
- execute.if.score(myScore, ">=", 10).run.say("High score!");
1745
- ```
1746
-
1747
- ### Selectors
247
+ `Game` has `begin(seed, roster, opts?)` (a roster entry is a role, or `{ role, at, ...sample }` so the first tick carries the position and `start` sees it), `tick(n?)`, `after(duration)` and `at(duration)` (they land on the exact tick), `sample(players, entities?)` (one tick with several players and entity moves), `dispatch(event)`, `terrain(box, block)`, `setBlocks`, `spawnEntity`, `moveEntities`, `removeEntity`, `setGamemode`, `container(at, items, { by?, initial?, block? })` (a declared container's contents, as the engine reports them), `other(name, sample)`, `custom(name, data, player?)`, `walkResult(ok, detail?, { player? | id? })`, `state`, `actions`, `last`, `of(type)`, `all(type)`, `effects(kind)`, `effectsAll(kind)`, `announcements(to?)`, `said()`, `checkpoint()`, `Game.resume(challenge, json)`, `replay()` (from the state the game started with, also after `resume`). A `Fake` player (`game.player(name)`) has `moveTo(pos, extrema?)`, `enters(zone)`, `leaves(zone)`, `has(item, count)` (one slot per item, per game), `picksUp(item, count?, at?)`, `uses(block, at, item?)`, `breaks(block, at)`, `places(block, at)`, `says(text)`, `swings(animation?)`, `vote(voteId, option)`, `hurt(amount, cause, attacker?)`, `dies(cause?, killer?)`, `respawns()`.
1748
248
 
1749
- ```typescript
1750
- import { Selector } from "sandstone";
249
+ The harness alone does not sandbox the reducer. `kradle challenge test <slug>` builds the challenge and runs its tests against the built bundle inside the arena's SES sandbox. `sandboxed(source)` returns the bundle under the command (`KRADLE_CHALLENGE_BUNDLE` set) and the plain import elsewhere; it is async, so await it at the top of the test file. Under the command a `Game` on the imported source throws, and the command counts the Games built on the bundle as its proof that the rules ran inside the sandbox. A test file lives at `tests/<slug>.test.ts`, `tests/<slug>/`, or `challenges/<slug>/tests/`.
1751
250
 
1752
- // With arguments
1753
- Selector("@a", { tag: "my_tag" });
1754
- Selector("@e", { type: "zombie", limit: 1, sort: "nearest" });
1755
-
1756
- // NBT check
1757
- Selector("@s", {
1758
- nbt: { Inventory: [{ id: "minecraft:diamond" }] }
1759
- });
1760
- ```
251
+ ---
1761
252
 
1762
- ### Relative/Absolute Coordinates
253
+ ## Build and Run
1763
254
 
1764
- ```typescript
1765
- import { rel, abs } from "sandstone";
255
+ `kradle challenge build <slug>` bundles `challenge.ts` into one file (`kradle-runtime/challenge.js`), loads it once inside the sandbox (module code and `init()`), uploads it and activates it with the challenge's configuration. A challenge runs only after that activation. The server runs the `KradleEngine` plugin; the arena connects the reducer to it. The run's event log is uploaded as the `runtime` recording.
1766
256
 
1767
- rel(0, 1, 0); // ~ ~1 ~
1768
- abs(0, 64, 0); // 0 64 0
1769
- ```
257
+ `kradle challenge run <slug>` starts a run in the cloud. `kradle challenge run --local` (internal) needs the plugin jar in the local server.
1770
258
 
1771
259
  ---
1772
260
 
1773
261
  ## Complete Examples
1774
262
 
1775
- ### Example 1: Speed Challenge - First to Kill 2 Pigs
1776
-
1777
- ```typescript
1778
- import { createChallenge, Actions, forEveryPlayer } from "@kradle/challenges-sdk";
1779
- import { _ } from "sandstone";
1780
-
1781
- createChallenge({
1782
- name: "pig-farming",
1783
- roles: ["farmer"] as const,
1784
- GAME_DURATION: 2 * 60 * 20, // 2 minutes
1785
- custom_variables: {
1786
- pigs_farmed: {
1787
- type: "individual",
1788
- objective_type: "minecraft.killed:minecraft.pig",
1789
- default: 0,
1790
- updater: (value, { main_score }) => {
1791
- main_score.set(value);
1792
- },
1793
- },
1794
- game_over: {
1795
- type: "global",
1796
- updater: (value, { pigs_farmed }) => {
1797
- value.set(0);
1798
- forEveryPlayer(() => {
1799
- _.if(pigs_farmed.greaterOrEqualThan(2), () => {
1800
- value.set(1);
1801
- });
1802
- });
1803
- },
1804
- },
1805
- },
1806
- })
1807
- .events(() => ({
1808
- start_challenge: () => {
1809
- Actions.setTime({ time: "day" });
1810
- Actions.announce({ message: "First to kill 2 pigs wins!" });
1811
- },
1812
- init_participants: () => {
1813
- Actions.give({ target: "all", item: "minecraft:iron_sword", count: 1 });
1814
- },
1815
- }))
1816
- .custom_events(({ pigs_farmed }) => [
1817
- {
1818
- score: pigs_farmed,
1819
- target: 1,
1820
- mode: "fire_once",
1821
- actions: () => {
1822
- Actions.announce({ message: "First pig down!" });
1823
- },
1824
- },
1825
- ])
1826
- .end_condition(({ game_over }) => game_over.equalTo(1))
1827
- .win_conditions(({ pigs_farmed }, { farmer }) => ({
1828
- [farmer]: pigs_farmed.greaterOrEqualThan(2),
1829
- }));
1830
- ```
1831
-
1832
- ### Example 2: Climb Challenge - Reach Highest Point
1833
-
1834
- ```typescript
1835
- import { createChallenge, Actions, forEveryPlayer } from "@kradle/challenges-sdk";
1836
- import { _, execute } from "sandstone";
1837
-
1838
- createChallenge({
1839
- name: "climb",
1840
- roles: ["climber"] as const,
1841
- GAME_DURATION: 3 * 60 * 20,
1842
- custom_variables: {
1843
- current_height: {
1844
- type: "individual",
1845
- objective_type: "dummy",
1846
- updater: (value) => {
1847
- value.set(Actions.getCurrentPlayerPosition().y)
1848
- },
1849
- },
1850
- max_height: {
1851
- type: "individual",
1852
- updater: (value, { current_height, main_score }) => {
1853
- _.if(current_height.greaterThan(value), () => {
1854
- value.set(current_height);
1855
- });
1856
- main_score.set(value);
1857
- },
1858
- },
1859
- max_height_global: {
1860
- type: "global",
1861
- updater: (value, { max_height }) => {
1862
- // Pass `min: -1000` because climber Y can go below 0 (lava drops, void).
1863
- value.set(Actions.maxPlayerScore({ score: max_height, min: -1000 }));
1864
- },
1865
- },
1866
- is_winner: {
1867
- type: "individual",
1868
- updater: (value, { max_height, max_height_global, has_never_died }) => {
1869
- value.set(0);
1870
- _.if(_.and(
1871
- max_height.equalTo(max_height_global),
1872
- max_height.greaterThan(0),
1873
- has_never_died.equalTo(1)
1874
- ), () => {
1875
- value.set(1);
1876
- });
1877
- },
1878
- },
1879
- },
1880
- })
1881
- .events(() => ({
1882
- start_challenge: () => {
1883
- Actions.setTime({ time: "day" });
1884
- Actions.announce({ message: "Climb as high as you can!" });
1885
- },
1886
- init_participants: () => {
1887
- Actions.give({ target: "all", item: "minecraft:cobblestone", count: 64 });
1888
- Actions.give({ target: "all", item: "minecraft:cobblestone", count: 64 });
1889
- },
1890
- }))
1891
- .custom_events(() => [])
1892
- .end_condition(({ game_timer }) => game_timer.greaterThan(3 * 60 * 20))
1893
- .win_conditions(({ is_winner }, { climber }) => ({
1894
- [climber]: is_winner.equalTo(1),
1895
- }));
1896
- ```
263
+ The SDK ships five reference challenges in `src/runtime/examples/`: Climb (scores by height, `life: "single"`), Capture the Flag (two roles, zones, a flag carried in the inventory), Four Bridges (phases, votes, a judge context), Skywars (markers, chests, the last one standing), Sheep Herding (the entity mirror, a fence watched through the block mirror). They ship in the package under `dist/runtime/examples/` (sources in `src/runtime/examples/`).
1897
264
 
1898
- ### Example 3: Battle Royale - Last Player Standing
1899
-
1900
- ```typescript
1901
- import { createChallenge, Actions } from "@kradle/challenges-sdk";
1902
- import { _ } from "sandstone";
1903
-
1904
- createChallenge({
1905
- name: "battle-royale",
1906
- roles: ["fighter"] as const,
1907
- GAME_DURATION: 5 * 60 * 20,
1908
- custom_variables: {
1909
- kills: {
1910
- type: "individual",
1911
- objective_type: "playerKillCount",
1912
- default: 0,
1913
- updater: (value, { main_score }) => {
1914
- main_score.set(value);
1915
- },
1916
- },
1917
- sole_survivor: {
1918
- type: "individual",
1919
- updater: (value, { alive_players, has_never_died }) => {
1920
- value.set(0);
1921
- _.if(_.and(
1922
- alive_players.equalTo(1),
1923
- has_never_died.equalTo(1)
1924
- ), () => {
1925
- value.set(1);
1926
- });
1927
- },
1928
- },
1929
- },
1930
- })
1931
- .events(() => ({
1932
- start_challenge: () => {
1933
- Actions.setTime({ time: "day" });
1934
- Actions.gamerule({ rule: "naturalRegeneration", value: false });
1935
- Actions.announce({ message: "Last player standing wins!" });
1936
- },
1937
- init_participants: () => {
1938
- Actions.give({ target: "all", item: "minecraft:stone_sword", count: 1 });
1939
- Actions.give({ target: "all", item: "minecraft:leather_chestplate", count: 1 });
1940
- Actions.give({ target: "all", item: "minecraft:cooked_beef", count: 10 });
1941
- },
1942
- }))
1943
- .custom_events(({ kills }) => [
1944
- {
1945
- score: kills,
1946
- target: 1,
1947
- mode: "fire_once",
1948
- actions: () => {
1949
- Actions.announce({ message: "First blood!" });
1950
- },
1951
- },
1952
- ])
1953
- .end_condition(({ alive_players }) => alive_players.equalTo(1))
1954
- .win_conditions(({ sole_survivor }, { fighter }) => ({
1955
- [fighter]: sole_survivor.equalTo(1),
1956
- }));
1957
- ```
265
+ A complete small challenge, Climb:
1958
266
 
1959
- ### Example 4: Team-Based - Hunters vs Protectors
1960
-
1961
- ```typescript
1962
- import { createChallenge, Actions, forEveryPlayer } from "@kradle/challenges-sdk";
1963
- import { _ } from "sandstone";
1964
-
1965
- createChallenge({
1966
- name: "pig-farming-v2",
1967
- roles: ["pighunter", "pigsaver"] as const,
1968
- GAME_DURATION: 2 * 60 * 20,
1969
- custom_variables: {
1970
- pigs_killed: {
1971
- type: "individual",
1972
- objective_type: "minecraft.killed:minecraft.pig",
1973
- default: 0,
1974
- updater: (value, { main_score }) => {
1975
- main_score.set(value);
1976
- },
1977
- },
1978
- pig_killed_max: {
1979
- type: "global",
1980
- updater: (value, { pigs_killed }) => {
1981
- value.set(Actions.maxPlayerScore({ score: pigs_killed }));
1982
- },
1983
- },
267
+ ```ts
268
+ import { defineChallenge, End, minutes, Win } from "@kradle/challenges-sdk";
269
+ import { config } from "./config";
270
+
271
+ const FLOOR_Y = -60;
272
+
273
+ export default defineChallenge(config, {
274
+ duration: minutes(2),
275
+ life: "single",
276
+ world: { time: "day" },
277
+ player: () => ({ maxY: FLOOR_Y }),
278
+ start(g) {
279
+ for (const p of g.players) g.give(p, "minecraft:cobblestone", 64);
280
+ g.say("The Climb Challenge has begun!");
1984
281
  },
1985
- })
1986
- .events((vars, { pighunter, pigsaver }) => ({
1987
- start_challenge: () => {
1988
- Actions.setTime({ time: "day" });
1989
- Actions.announce({ message: "Hunters: Kill 2 pigs! Protectors: Stop them!" });
1990
- },
1991
- init_participants: () => {
1992
- // Different items based on role would be set via role-specific logic
1993
- Actions.give({ target: "all", item: "minecraft:wooden_sword", count: 1 });
1994
- },
1995
- }))
1996
- .custom_events(() => [])
1997
- .end_condition(({ pig_killed_max }) => pig_killed_max.greaterOrEqualThan(2))
1998
- .win_conditions(({ pig_killed_max }, { pighunter, pigsaver }) => ({
1999
- [pighunter]: pig_killed_max.greaterOrEqualThan(2),
2000
- [pigsaver]: pig_killed_max.lowerThan(2),
2001
- }));
2002
- ```
2003
-
2004
- ### Example 5: Capture the Flag
2005
-
2006
- ```typescript
2007
- import { createChallenge, Actions } from "@kradle/challenges-sdk";
2008
- import { _, Selector, rel } from "sandstone";
2009
- import type { Score } from "sandstone";
2010
-
2011
- createChallenge({
2012
- name: "capture-the-flag",
2013
- roles: ["red_team", "blue_team"] as const,
2014
- GAME_DURATION: 5 * 60 * 20,
2015
- custom_variables: {
2016
- holds_red_banner: {
2017
- type: "individual",
2018
- updater: (value: Score) => {
2019
- value.set(0);
2020
- _.if(Selector("@s", {
2021
- nbt: { Inventory: [{ id: "minecraft:red_banner" }] }
2022
- }), () => {
2023
- value.set(1);
2024
- });
2025
- },
2026
- },
2027
- stands_blue_wool: {
2028
- type: "individual",
2029
- updater: (value: Score) => {
2030
- value.set(0);
2031
- _.if(_.block(rel(0, -1, 0), "minecraft:blue_wool"), () => {
2032
- value.set(1);
2033
- });
2034
- },
282
+ on: {
283
+ move(g, { player: p, maxY }) {
284
+ const y = Math.floor(maxY);
285
+ if (p.eliminated || y <= p.maxY) return;
286
+ p.maxY = y;
287
+ p.score = y - FLOOR_Y;
2035
288
  },
2036
- captured_flag: {
2037
- type: "individual",
2038
- updater: (value, { holds_red_banner, stands_blue_wool, main_score }) => {
2039
- value.set(0);
2040
- _.if(_.and(
2041
- holds_red_banner.equalTo(1),
2042
- stands_blue_wool.equalTo(1)
2043
- ), () => {
2044
- value.set(1);
2045
- main_score.set(1);
2046
- });
2047
- },
289
+ died(g, { player, killer }) {
290
+ if (killer) g.say(`${player.name} was killed by ${killer.name}`);
2048
291
  },
2049
292
  },
2050
- })
2051
- .events(() => ({
2052
- start_challenge: () => {
2053
- Actions.announce({ message: "Capture the enemy flag and return to base!" });
2054
- },
2055
- init_participants: () => {
2056
- Actions.give({ target: "all", item: "minecraft:iron_sword", count: 1 });
2057
- },
2058
- }))
2059
- .custom_events(({ captured_flag }) => [
2060
- {
2061
- score: captured_flag,
2062
- target: 1,
2063
- mode: "fire_once",
2064
- actions: () => {
2065
- Actions.announce({ message: "Flag captured! Game over!" });
2066
- },
2067
- },
2068
- ])
2069
- .end_condition(({ captured_flag }) => captured_flag.equalTo(1))
2070
- .win_conditions(({ captured_flag }, { red_team, blue_team }) => ({
2071
- [red_team]: captured_flag.equalTo(0),
2072
- [blue_team]: captured_flag.equalTo(1),
2073
- }));
293
+ end: End.allEliminated,
294
+ winners: Win.topScore({ min: 1 }),
295
+ });
2074
296
  ```
2075
297
 
2076
298
  ---
2077
299
 
2078
300
  ## Common Patterns
2079
301
 
2080
- | # | Pattern | Use when | Canonical challenge |
2081
- |---|---------|----------|---------------------|
2082
- | 1 | Sync variable to main_score | Score should display on the watcher leaderboard | sprint, patience |
2083
- | 2 | Find global maximum | "Highest score wins" comparisons | crafters-race, splat-rush |
2084
- | 3 | Winner detection (has max + alive) | Latched is_winner that requires surviving | prisoners-dilemma, king-of-the-hill |
2085
- | 4 | Track player position | Per-player coordinate snapshot for distance/region checks | sprint, lighthouse |
2086
- | 5 | Check inventory for item | "Has the agent crafted X yet?" | crafters-race, biome-bazaar |
2087
- | 6 | Check block below player | "Is the agent on a specific tile?" — use `Actions.standingOnBlock` | sacrifice, reputation |
2088
- | 7 | Count entities | Wave / mob accounting | wave-survivor, zombie-survival |
2089
- | 8 | Simple end condition | Single-condition timer or score gate | sprint |
2090
- | 9 | Multi-condition end | OR of timer / score / event gates | crafters-race |
2091
- | 10 | Opposing-team win conditions | Capture-the-flag style 0/1 flag state | castle-ctf, capture-the-flag |
2092
- | 11 | Per-participant win attribution inside a single role | Same role, but only some win (saboteur vs miners) | saboteur |
2093
- | 12 | Sequential turn-taking | "One player acts at a time" — current_voter gate | cascade, adaptive |
2094
- | 13 | Two-phase game (sequential broadcast → simultaneous decision) | Vote after public discussion | common-knowledge |
2095
-
2096
- ### Pattern 1: Sync Variable to Main Score
2097
-
2098
- ```typescript
2099
- my_variable: {
2100
- type: "individual",
2101
- objective_type: "some_criterion",
2102
- updater: (value, { main_score }) => {
2103
- main_score.set(value);
2104
- },
2105
- }
2106
- ```
2107
-
2108
- ### Pattern 2: Find Global Maximum
2109
-
2110
- ```typescript
2111
- max_global: {
2112
- type: "global",
2113
- updater: (value, { individual_score }) => {
2114
- value.set(Actions.maxPlayerScore({ score: individual_score }));
2115
- },
2116
- }
2117
- ```
2118
-
2119
- 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.
2120
-
2121
- ### Pattern 3: Winner Detection (Has Max Score + Alive)
2122
-
2123
- ```typescript
2124
- is_winner: {
2125
- type: "individual",
2126
- updater: (value, { main_score, max_global, has_never_died }) => {
2127
- value.set(0);
2128
- _.if(_.and(
2129
- main_score.equalTo(max_global),
2130
- main_score.greaterThan(0),
2131
- has_never_died.equalTo(1)
2132
- ), () => {
2133
- value.set(1);
2134
- });
2135
- },
2136
- }
2137
- ```
2138
-
2139
- ### Pattern 4: Track Player Position
2140
-
2141
- ```typescript
2142
- current_y: {
2143
- type: "individual",
2144
- objective_type: "dummy",
2145
- updater: (value) => {
2146
- // Prefered way: using the dedicated Action
2147
- value.set(Actions.getCurrentPlayerPosition().y)
2148
-
2149
- // Alternative way: using execute.store + data.get
2150
- execute.as("@s").store.result.score(value).run.data.get.entity("@s", "Pos[1]");
2151
- },
2152
- }
2153
- ```
2154
-
2155
- ### Pattern 5: Check Inventory for Item
2156
-
2157
- ```typescript
2158
- has_diamond: {
2159
- type: "individual",
2160
- updater: (value: Score) => {
2161
- value.set(0);
2162
- _.if(Selector("@s", {
2163
- nbt: { Inventory: [{ id: "minecraft:diamond" }] }
2164
- }), () => {
2165
- value.set(1);
2166
- });
2167
- },
2168
- }
2169
- ```
2170
-
2171
- ### Pattern 6: Check Block Below Player
2172
-
2173
- ```typescript
2174
- on_gold_block: {
2175
- type: "individual",
2176
- updater: (value: Score) => {
2177
- value.set(0);
2178
- _.if(_.block(rel(0, -1, 0), "minecraft:gold_block"), () => {
2179
- value.set(1);
2180
- });
2181
- },
2182
- }
2183
- ```
2184
-
2185
- ### Pattern 7: Count Entities
2186
-
2187
- ```typescript
2188
- zombie_count: {
2189
- type: "global",
2190
- updater: (value) => {
2191
- value.set(Actions.countEntities({ entity: "minecraft:zombie" }));
2192
- },
2193
- }
2194
- ```
2195
-
2196
- 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).
2197
-
2198
- ### Pattern 8: Simple End Condition
2199
-
2200
- ```typescript
2201
- .end_condition(({ objective_complete }) => objective_complete.equalTo(1))
2202
- ```
2203
-
2204
- ### Pattern 9: Multi-Condition End
2205
-
2206
- ```typescript
2207
- .end_condition(({ alive_players, objective_complete }) =>
2208
- _.or(
2209
- alive_players.equalTo(1),
2210
- objective_complete.equalTo(1)
2211
- )
2212
- )
2213
- ```
302
+ **Score every tick from the mirror.** `on.tick: (g) => { for (const p of g.players) p.score = g.entities({ box: penOf(p), type: "minecraft:sheep" }).length; }`. The score reaches the run result when it changes.
2214
303
 
2215
- ### Pattern 10: Opposing Team Win Conditions
304
+ **Phases.** `phases: { build: { duration: "2m", next: "fight" }, fight: { duration: "3m", next: "END", on: { died(g, { player }) { g.eliminate(player); } } } }`. `g.goTo("fight")` moves early; `enter`/`exit` run on the transition.
2216
305
 
2217
- ```typescript
2218
- .win_conditions(({ team_a_score, team_b_score }, { team_a, team_b }) => ({
2219
- [team_a]: team_a_score.greaterThan(team_b_score),
2220
- [team_b]: team_b_score.greaterThan(team_a_score),
2221
- }))
2222
- ```
306
+ **Timers.** `timers: ["release"]`, `g.schedule("release", "30s")` in `start`, `on.timer(g, { name })` fires once; scheduling again resets it.
2223
307
 
2224
- ### Pattern 11: Per-participant Win Attribution Inside a Single Role
2225
-
2226
- 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 })`:
2227
-
2228
- ```typescript
2229
- .win_conditions(({ saboteur_alive, diamonds_mined }, { player }) => ({
2230
- [player]: _.or(
2231
- _.and(
2232
- Selector("@s", { tag: "saboteur" } as any),
2233
- saboteur_alive.equalTo(1),
2234
- diamonds_mined.lowerThan(4),
2235
- ),
2236
- _.and(
2237
- Selector("@s", { tag: "!saboteur" } as any),
2238
- _.or(diamonds_mined.greaterOrEqualThan(4), saboteur_alive.equalTo(0)),
2239
- ),
2240
- ),
2241
- }))
2242
- ```
308
+ **Deliveries.** Declare the chest: `containers: { delivery: { x: 8, y: -60, z: 0 } }`. The engine reports its contents; the rule reads them: `container(g, { at, items }) { if (items["minecraft:white_wool"] >= 1) g.game.delivered = true; }`, or `g.contents("delivery")?.["minecraft:bread"] ?? 0` in an end condition. Do not infer a deposit from an inventory decrease: an item dropped or eaten leaves the inventory too. In a test: `game.container(CHEST, { "minecraft:white_wool": 1 }, { by: "Solo" })`.
2243
309
 
2244
- 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.
2245
-
2246
- ### Pattern 12: Sequential turn-taking (one player acts at a time)
2247
-
2248
- 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:
2249
-
2250
- ```typescript
2251
- custom_variables: {
2252
- // ... your other variables ...
2253
- current_voter: { type: "global", default: 1 },
2254
- has_voted: { type: "individual", objective_type: "dummy", default: 0 },
2255
- vote: {
2256
- type: "individual",
2257
- objective_type: "dummy",
2258
- default: -1,
2259
- updater: (value, { current_voter, has_voted, player_number }) => {
2260
- forEveryPlayer(() => {
2261
- _.if(
2262
- _.and(has_voted.equalTo(0), player_number.equalTo(current_voter)),
2263
- () => {
2264
- _.if(Actions.standingOnBlock(RED_PAD), () => {
2265
- value.set(0);
2266
- has_voted.set(1);
2267
- current_voter.add(1);
2268
- });
2269
- _.if(Actions.standingOnBlock(BLUE_PAD), () => {
2270
- value.set(1);
2271
- has_voted.set(1);
2272
- current_voter.add(1);
2273
- });
2274
- },
2275
- );
2276
- });
2277
- },
2278
- },
2279
- },
2280
- // Announce each turn boundary via custom_events keyed on current_voter:
2281
- .custom_events(({ current_voter }) => [
2282
- { score: current_voter, target: 2, mode: "fire_once",
2283
- actions: () => Actions.announce({ message: [{ text: "Voter 2's turn.", color: "yellow" }] }) },
2284
- { score: current_voter, target: 3, mode: "fire_once",
2285
- actions: () => Actions.announce({ message: [{ text: "Voter 3's turn.", color: "yellow" }] }) },
2286
- // ... one per voter beyond the first ...
2287
- ])
2288
- ```
310
+ **Votes.** `config.challengeConfig.votingOptions: { bridge: ["north", "south"] }`, `votes: { bridge: { final: true } }`, `on.vote(g, { player, voteId, option })` for accepted votes, `g.votes("bridge")` for the tally, `g.openVote("bridge")` for a new round. A vote on players: `votes: { player: { final: true } }`, then `g.votes("player")` maps each voter to the name it voted for (`skills.voteForPlayer` on the agent side). In `on.vote`, check `voteId` first: `if (voteId === "bridge")` narrows `option` to `"north" | "south"`; without the check it is a string, because the `player` vote's options are names.
2289
311
 
2290
- 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.
312
+ **Zones.** `zones: { goal: { from, to } }`, `on.zoneEntered(g, { player, zone })`, `player.zones` for the current zones, `g.inZone(player, box)` for any box.
2291
313
 
2292
- ### Pattern 13: Two-phase game (sequential broadcast, then simultaneous decision)
314
+ **Markers.** Tag entities in the world (`kradle.chest.*`), declare `markers: { tags: ["kradle.chest."] }`, read `g.markers("kradle.chest.")` in `setup` to place blocks or chests at their positions.
2293
315
 
2294
- 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:
316
+ **Items and inventories.** `g.give(p, "minecraft:wheat", 16)`, `g.count(p, "minecraft:wheat")`, `on.inventory(g, { player, item, count })` when a count changes, `on.pickup` when an item is picked up.
2295
317
 
2296
- ```typescript
2297
- custom_variables: {
2298
- // ... phase 1: same as Pattern 12 ...
2299
- broadcasts_done: {
2300
- type: "global",
2301
- default: 0,
2302
- updater: (value, { has_broadcast }) => {
2303
- value.set(0);
2304
- forEveryPlayer(() => { value.add(has_broadcast); });
2305
- },
2306
- },
2307
- has_voted_final: { type: "individual", objective_type: "dummy", default: 0 },
2308
- final_vote: {
2309
- type: "individual",
2310
- objective_type: "dummy",
2311
- default: -1,
2312
- updater: (value, { broadcasts_done, has_voted_final, player_number }) => {
2313
- // Per-player room layout — keeps each player's vote private until all cast.
2314
- const FINAL_PADS = [
2315
- { red: FINAL_RED_P1, blue: FINAL_BLUE_P1 },
2316
- { red: FINAL_RED_P2, blue: FINAL_BLUE_P2 },
2317
- { red: FINAL_RED_P3, blue: FINAL_BLUE_P3 },
2318
- ];
2319
- forEveryPlayer(() => {
2320
- _.if(
2321
- _.and(broadcasts_done.equalTo(3), has_voted_final.equalTo(0)),
2322
- () => {
2323
- FINAL_PADS.forEach((pads, idx) => {
2324
- _.if(player_number.equalTo(idx + 1), () => {
2325
- _.if(Actions.standingOnBlock(pads.red), () => {
2326
- value.set(0);
2327
- has_voted_final.set(1);
2328
- });
2329
- _.if(Actions.standingOnBlock(pads.blue), () => {
2330
- value.set(1);
2331
- has_voted_final.set(1);
2332
- });
2333
- });
2334
- });
2335
- },
2336
- );
2337
- });
2338
- },
2339
- },
2340
- },
2341
- // In init_participants, tell each player their player_number — without it, agents can't tell
2342
- // which "Voter 2's turn" announcement is theirs and can't navigate to the right phase-2 room.
2343
- // One tellraw to "all" with player_number embedded does it (each recipient sees their own value):
2344
- // Actions.tellraw({ target: "all", message: ["Your player number is ", player_number] });
2345
- ```
318
+ **Trades between agents.** The agents trade through their skills; the reducer sees the result as inventory changes. Nothing to declare.
2346
319
 
2347
- **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.
320
+ **A judge.** `judgeContext: (g) => ({ bridges: g.game.bridges })` ships JSON with `game_over` for the LLM judge (`config.judging`).
2348
321
 
2349
- **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.
322
+ **Stored variables.** `g.store("round", g.game.round)` and `g.store("coins", p.coins, p)` keep the last value per name; they reach the run result (`customVars`) and the `runs get` table, like the datapack SDK's `log_variable(store: true)` did.
2350
323
 
2351
324
  ---
2352
325
 
2353
326
  ## Common Gotchas
2354
327
 
2355
- 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.
2356
-
2357
- ### Relative coords in global custom_variable updaters resolve at world origin (0, 0, 0)
2358
-
2359
- `_.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.
2360
-
2361
- **Fix:** wrap the updater body in `forEveryPlayer(() => { ... })` so the relative coords resolve at `@s` (each player's position):
2362
-
2363
- ```ts
2364
- trigger_pressed: {
2365
- type: "global",
2366
- default: 0,
2367
- updater: (value) => {
2368
- forEveryPlayer(() => {
2369
- _.if(_.block(rel(0, -1, 0), "minecraft:gold_block"), () => {
2370
- value.set(1);
2371
- });
2372
- });
2373
- },
2374
- }
2375
- ```
2376
-
2377
- This bit sequence-pad's wool-pad detection when the updater ran at world origin instead of per-player.
2378
-
2379
- ### Sandstone NBT serialization: `true` → `{}`, `false` → `0b`
2380
-
2381
- 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.
2382
-
2383
- **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.
2384
-
2385
- ### `Actions.tagEntity?.(...)` / `Actions.tagPlayer` don't exist
2386
-
2387
- 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.
2388
-
2389
- **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.
2390
-
2391
- ### `score.lessThan` doesn't exist; use `lowerThan`
2392
-
2393
- 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).
2394
-
2395
- ### `scheduled_events` / `setInterval`-style helpers don't exist
2396
-
2397
- 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(...)`.
2398
-
2399
- ### `gamerule "pvp"` is not a real gamerule in 1.20.4
2400
-
2401
- PvP is on by default in flat-world. Spyglass validation rejects this rule. Just don't set it.
2402
-
2403
- ### End-state names are validated at build time against `config.ts → endStates`
2404
-
2405
- `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.
2406
-
2407
- ### `events.on_tick` and `.custom_events` run as the server entity, not as participants
2408
-
2409
- 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.
2410
-
2411
- ### `Selector("@s", { x, y, z })` does NOT constrain position
2412
-
2413
- 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.
2414
-
2415
- **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:
2416
-
2417
- ```typescript
2418
- // ❌ Silently passes for every entity:
2419
- Selector("@s", { tag: "alice", x: PAD.x, y: PAD.y + 1, z: PAD.z })
2420
-
2421
- // ✅ Matches only when feet are at (PAD.x, PAD.y+1, PAD.z):
2422
- Selector("@s", { tag: "alice", x: PAD.x, y: PAD.y + 1, z: PAD.z, dx: 0, dy: 1, dz: 0 })
2423
- // or — preferred:
2424
- Actions.standingOnBlock({ x: PAD.x, y: PAD.y, z: PAD.z, role: "alice" })
2425
- ```
2426
-
2427
- ### `minecraft.picked_up:<item>` does NOT fire on `Actions.give` (also: does NOT decrement on `clear`)
2428
-
2429
- 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).
2430
-
2431
- ### Don't reach for `raw("scoreboard players add ...")`
2432
-
2433
- 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.
2434
-
2435
- ### Roles get tagged with the bare role name, not `kradle_<role>`
2436
-
2437
- 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"`.
328
+ - `Date.now()` or `Math.random()` in a rule passes the plain harness and fails under `kradle challenge test` and in the arena. Use `g.tick`, `g.elapsed`, `g.rand`.
329
+ - A helper typed from the challenge value (`typeof challenge.ctx`) and passed as a rule value makes the challenge's type circular. Type helpers with `ContextOf<typeof config, typeof state>` instead; then `endState: classify` is fine.
330
+ - The state is JSON. A `Set`, a `Map`, a class instance or a function in `g.game` fails the reduction.
331
+ - Your per-player fields may not reuse the SDK's names (`score`, `pos`, `alive`, ...).
332
+ - `g.blockAt` is `undefined` outside the loaded chunks. Narrow `mirror.blocks` only when you know the box.
333
+ - `g.command("fill ...")` is refused: the mirror would miss the change. Use `g.fill`.
334
+ - A zone edge or a block change is seen on the next reducer tick (2 server ticks), not on the server tick itself.
335
+ - `setup(g)` effects go out as one batch and the game starts after the engine confirms them; do not read the built blocks in `setup`.
336
+ - Under `kradle challenge test`, `new Game(source)` on the imported source throws. Pass the result of `await sandboxed(source)`.
2438
337
 
2439
338
  ---
2440
339
 
2441
340
  ## Tips for LLMs
2442
341
 
2443
- 1. **Always use `as const`** for roles array to get proper type inference
2444
- 2. **Updaters should be idempotent** - they run every tick
2445
- 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.
2446
- 4. **Import `_` from sandstone** for conditions (`_.if`, `_.and`, `_.or`)
2447
- 5. **Variables are Scores** - use `.set()`, `.add()`, comparison methods
2448
- 6. **Main score is displayed** - sync your primary metric to `main_score`
2449
- 7. **Time is in ticks** - multiply seconds by 20
2450
- 8. **Win conditions are per-role** - each role needs its own condition
2451
- 9. **Custom events fire per-player** for individual variables
342
+ - Start from the closest reference challenge and keep its structure: `config`, `state`, `type G`, helpers, `defineChallenge`.
343
+ - Put every fact you need in the state; recompute from the mirror instead of caching what the engine already keeps.
344
+ - Write the tests first with the harness: one test per rule, `game.replay()` at the end of each.
345
+ - Run `kradle challenge test <slug>` before `kradle challenge build`: the sandbox catches the clock, the random and the timers.
346
+ - Keep `config.ts` as the single source of the roles, the end states, the locations and the vote options; the types come from it.