|
1 | 1 | # Create / Update an Environment |
2 | 2 |
|
| 3 | +## Step 0: Ask whether this is a Game Arena environment |
| 4 | + |
| 5 | +**Before writing any code, ask the user** — the answer changes how failures and |
| 6 | +illegal moves must be scored (Step 3b), and there is no way to infer it from the |
| 7 | +game rules alone. |
| 8 | + |
| 9 | +``` |
| 10 | +AskUserQuestion({ |
| 11 | + questions: [{ |
| 12 | + question: "Is this a Game Arena environment (LLM-vs-LLM, results feed the Elo leaderboard)?", |
| 13 | + header: "Env type", |
| 14 | + multiSelect: false, |
| 15 | + options: [ |
| 16 | + {label: "Game Arena", |
| 17 | + description: "Agents are language models behind a harness; episodes are scored into a cross-model Elo rating. Requires the failure semantics in Step 3b."}, |
| 18 | + {label: "Regular", |
| 19 | + description: "Classic Kaggle simulation competition or a standalone env. Per-agent INVALID/ERROR statuses are fine; use the framework defaults."}, |
| 20 | + ] |
| 21 | + }] |
| 22 | +}) |
| 23 | +``` |
| 24 | + |
| 25 | +Signals that it is Game Arena, worth mentioning when you ask: the env ships a |
| 26 | +`harness.py`, agents receive natural-language prompts, or the user talks about |
| 27 | +comparing models rather than comparing submissions. |
| 28 | + |
| 29 | +If **Regular**, skip Step 3b and use the plain patterns in Step 3. |
| 30 | +If **Game Arena**, Step 3b is mandatory. |
| 31 | + |
3 | 32 | ## Step 1: Create the directory and files |
4 | 33 |
|
5 | 34 | Create `kaggle_environments/envs/<name>/` with: |
@@ -145,7 +174,8 @@ from .agents import agents # or define inline |
145 | 174 |
|
146 | 175 | ### Common interpreter patterns |
147 | 176 |
|
148 | | -**Validate and penalize invalid actions:** |
| 177 | +**Validate and penalize invalid actions** (regular envs only -- Game Arena envs |
| 178 | +must use Step 3b instead): |
149 | 179 | ```python |
150 | 180 | if state[i].action < 0 or state[i].action >= max_val: |
151 | 181 | state[i].status = "INVALID" |
@@ -175,6 +205,151 @@ state[0].observation.board = board # shared fields (if marked shared in spec) |
175 | 205 | state[0].observation.lastOpponentAction = state[1].action |
176 | 206 | ``` |
177 | 207 |
|
| 208 | +## Step 3b: Failure and illegal-move handling (Game Arena environments) |
| 209 | + |
| 210 | +Skip this section for regular environments. |
| 211 | + |
| 212 | +Game Arena episodes are scored into a **cross-model Elo leaderboard**, so every |
| 213 | +episode either produces a trustworthy result or must produce none at all. That |
| 214 | +forces a distinction the framework does not make for you: |
| 215 | + |
| 216 | +| What happened | Framework status | Correct outcome | |
| 217 | +|---|---|---| |
| 218 | +| Agent process raised, or the model provider errored | `ERROR` | **Void the episode.** Not a game result. | |
| 219 | +| Agent exceeded `actTimeout` | `TIMEOUT` | **Void the episode.** Same as above. | |
| 220 | +| Agent returned a well-formed action that breaks the rules | `INVALID`, or your own rule check | **Scored forfeit.** Offender loses, opponent wins. | |
| 221 | + |
| 222 | +The reason for the split: a crash or a timeout is a *broken participant*, not a |
| 223 | +model playing badly. Scoring it as a loss injects infrastructure flakiness into |
| 224 | +the ratings -- a model on a slow provider would rank below one on a fast |
| 225 | +provider for reasons that have nothing to do with gameplay. An illegal move is |
| 226 | +the opposite: the model *did* play, it just played badly, and failing to follow |
| 227 | +the action format is a genuine capability signal that belongs in the rating. |
| 228 | + |
| 229 | +`open_spiel_env` with `strictMode: false` (the default) is the reference |
| 230 | +implementation -- see `open_spiel_env.py`, the `agent_error` / `invalid_action` |
| 231 | +branches. `word_association` and `word_art` implement the same semantics. |
| 232 | +**New Game Arena environments must match it.** Do not add a config flag for |
| 233 | +this; the behavior is not per-env negotiable. |
| 234 | + |
| 235 | +### The pattern |
| 236 | + |
| 237 | +Define a module-level forfeit reward matching open_spiel's convention: |
| 238 | + |
| 239 | +```python |
| 240 | +# Statuses core.py assigns when an agent crashes or times out, as opposed to |
| 241 | +# returning a well-formed-but-illegal action. |
| 242 | +_FRAMEWORK_FAILURE_STATUSES = ("ERROR", "TIMEOUT") |
| 243 | + |
| 244 | +# Reward applied to the forfeiting side. The opponent receives the negation. |
| 245 | +DEFAULT_INVALID_ACTION_REWARD = -1 |
| 246 | +``` |
| 247 | + |
| 248 | +**Void on crash/timeout.** Force every seat to `ERROR`, except seats already |
| 249 | +`TIMEOUT` (which voids the episode identically and is more informative in the |
| 250 | +replay). `core.py` then nulls all rewards automatically: |
| 251 | + |
| 252 | +```python |
| 253 | +def _abort_on_agent_failure(state): |
| 254 | + """Void the episode if the framework marked any seat ERROR or TIMEOUT. |
| 255 | + Returns True if the episode was ended.""" |
| 256 | + if not any(s.status in _FRAMEWORK_FAILURE_STATUSES for s in state): |
| 257 | + return False |
| 258 | + for s in state: |
| 259 | + if s.status != "TIMEOUT": |
| 260 | + s.status = "ERROR" |
| 261 | + return True |
| 262 | +``` |
| 263 | + |
| 264 | +**Forfeit on an illegal move.** Every seat ends `DONE` -- including the |
| 265 | +offender -- so the episode scores normally: |
| 266 | + |
| 267 | +```python |
| 268 | +def forfeit(state, offending_seat): |
| 269 | + for i in range(len(state)): |
| 270 | + state[i].status = "DONE" |
| 271 | + state[i].reward = ( |
| 272 | + DEFAULT_INVALID_ACTION_REWARD |
| 273 | + if i == offending_seat |
| 274 | + else -DEFAULT_INVALID_ACTION_REWARD |
| 275 | + ) |
| 276 | +``` |
| 277 | + |
| 278 | +Never leave a seat in `INVALID` at episode end. `core.py` nulls the reward of |
| 279 | +any `ERROR`/`INVALID`/`TIMEOUT` agent, so an `INVALID` terminal status is |
| 280 | +indistinguishable from a crash downstream -- which is exactly the collapse this |
| 281 | +section exists to prevent. |
| 282 | + |
| 283 | +### Wiring it into the interpreter |
| 284 | + |
| 285 | +Check for framework failures **before** your action-processing code runs, so a |
| 286 | +crash cannot be laundered into a scored forfeit: |
| 287 | + |
| 288 | +```python |
| 289 | +def interpreter(state, env): |
| 290 | + if env.done: |
| 291 | + return state |
| 292 | + |
| 293 | + # A crashed or timed-out seat is a broken participant, not a player |
| 294 | + # making an illegal move. Void before process_action can rescore it. |
| 295 | + if _abort_on_agent_failure(state): |
| 296 | + return state |
| 297 | + |
| 298 | + forfeited = process_action(state, env.configuration) |
| 299 | + ... |
| 300 | +``` |
| 301 | + |
| 302 | +### Env-shape adjustments |
| 303 | + |
| 304 | +The two rules above are fixed; how they map onto your env is not. Decide these |
| 305 | +explicitly and write the reasoning into a docstring: |
| 306 | + |
| 307 | +* **Team games.** Scope the forfeit to the offender's *team*, not the lone |
| 308 | + seat -- crediting the offender's own partner would reward a team for its own |
| 309 | + foul. `word_association` (2v2) gives both offending seats |
| 310 | + `DEFAULT_INVALID_ACTION_REWARD` and both opponents its negation. |
| 311 | +* **Multi-game episodes.** A forfeit is terminal for the whole episode. Return |
| 312 | + a flag from your action processor and gate the next-game rollover on it; |
| 313 | + otherwise the rollover resets the forfeiting seats to `ACTIVE` and overwrites |
| 314 | + the forfeit rewards. (`word_association` had exactly this bug.) |
| 315 | +* **Running-score rewards.** If `reward` is a cumulative point total rather |
| 316 | + than a win/loss value, overwriting it with a flat ±1 discards every completed |
| 317 | + round. `word_art` deliberately diverges here: on `INVALID` it ends all seats |
| 318 | + `DONE` and **keeps the accumulated points**. Crash/timeout still voids. |
| 319 | + |
| 320 | +### Visualizers |
| 321 | + |
| 322 | +A visualizer that computes terminal state as `status === 'DONE'` will silently |
| 323 | +render nothing on a voided episode. Handle `ERROR`/`TIMEOUT` explicitly and show |
| 324 | +why the episode was voided: |
| 325 | + |
| 326 | +```ts |
| 327 | +const isVoided = step.some((p) => p?.status === 'ERROR' || p?.status === 'TIMEOUT'); |
| 328 | +const isGameOver = step.every((p) => p?.status === 'DONE') || isVoided; |
| 329 | +``` |
| 330 | + |
| 331 | +### Required tests |
| 332 | + |
| 333 | +```python |
| 334 | +@pytest.mark.parametrize("crash_seat", range(NUM_AGENTS)) |
| 335 | +def test_agent_crash_voids_the_episode(crash_seat): |
| 336 | + def crash(observation, configuration): |
| 337 | + raise RuntimeError("provider exploded") |
| 338 | + agents = [legal_agent] * NUM_AGENTS |
| 339 | + agents[crash_seat] = crash |
| 340 | + env = make("<name>") |
| 341 | + env.run(agents) |
| 342 | + assert [s.status for s in env.state] == ["ERROR"] * NUM_AGENTS |
| 343 | + assert [s.reward for s in env.state] == [None] * NUM_AGENTS |
| 344 | +``` |
| 345 | + |
| 346 | +Cover, at minimum: |
| 347 | +- crash on every seat -> all `ERROR`, all rewards `None` |
| 348 | +- `DeadlineExceeded` on every seat (from `kaggle_environments.errors`) -> the |
| 349 | + offending seat keeps `TIMEOUT`, the rest are `ERROR`, all rewards `None` |
| 350 | +- an illegal-but-well-formed action -> all `DONE` with ±1 rewards, never `None` |
| 351 | +- if the env supports multi-game episodes: a forfeit does not start a new game |
| 352 | + |
178 | 353 | ## Step 4: Write agents |
179 | 354 |
|
180 | 355 | Agent functions receive `(observation, configuration)` as Struct objects and return an action: |
@@ -213,6 +388,8 @@ def test_rewards(): |
213 | 388 |
|
214 | 389 |
|
215 | 390 | def test_invalid_action(): |
| 391 | + # Regular envs only. Game Arena envs assert the Step 3b shape instead: |
| 392 | + # statuses == ["DONE", "DONE"], rewards == [-1, 1] |
216 | 393 | env = make("<name>") |
217 | 394 | env.run([bad_agent, good_agent]) |
218 | 395 | json = env.toJSON() |
@@ -241,10 +418,22 @@ Follow the `create-visualizer` skill to build a web-based replay visualizer for |
241 | 418 |
|
242 | 419 | ## Checklist |
243 | 420 |
|
| 421 | +- [ ] Asked the user whether this is a Game Arena or regular environment (Step 0) |
244 | 422 | - [ ] `<name>.json` spec is valid JSON with all required top-level keys |
245 | 423 | - [ ] `<name>.py` exports `specification`, `interpreter`, `renderer`, `html_renderer` |
246 | 424 | - [ ] Interpreter handles: normal play, invalid actions, game-over conditions |
247 | 425 | - [ ] Rewards are set correctly for all outcomes (win/lose/draw/invalid) |
248 | 426 | - [ ] `__init__.py` exists (can be empty) |
249 | 427 | - [ ] Tests cover: normal completion, rewards, invalid actions, renderer output |
250 | 428 | - [ ] `uv run ruff check --fix . && uv run ruff format .` passes |
| 429 | + |
| 430 | +Game Arena environments additionally: |
| 431 | + |
| 432 | +- [ ] Crash/timeout voids the episode (all seats `ERROR`/`TIMEOUT`, all rewards `None`) |
| 433 | +- [ ] Illegal move is a scored forfeit (all seats `DONE`, ±`DEFAULT_INVALID_ACTION_REWARD`) |
| 434 | +- [ ] No seat can end an episode in `INVALID` |
| 435 | +- [ ] Failure check runs before action processing in the interpreter |
| 436 | +- [ ] Team scoping, multi-game terminality, and running-score handling decided and documented |
| 437 | +- [ ] Visualizer renders a voided episode instead of silently showing nothing |
| 438 | +- [ ] Per-seat crash and timeout tests exist, plus an illegal-move forfeit test |
| 439 | +- [ ] No config flag was added to make this behavior optional |
0 commit comments