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