Skip to content

Commit a4dce5a

Browse files
authored
Mimic open spiel's 'strictMode: false' in custom Game Arena envs (#1390)
* If any of the agents raise an exception or timeout, error out the whole game * If an agent makes an invalid move, set "DONE" status on all agents and use default forfeit rewards
1 parent b0e0a1f commit a4dce5a

9 files changed

Lines changed: 521 additions & 54 deletions

File tree

.agents/skills/create-environment/SKILL.md

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# Create / Update an Environment
22

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+
332
## Step 1: Create the directory and files
433

534
Create `kaggle_environments/envs/<name>/` with:
@@ -145,7 +174,8 @@ from .agents import agents # or define inline
145174

146175
### Common interpreter patterns
147176

148-
**Validate and penalize invalid actions:**
177+
**Validate and penalize invalid actions** (regular envs only -- Game Arena envs
178+
must use Step 3b instead):
149179
```python
150180
if state[i].action < 0 or state[i].action >= max_val:
151181
state[i].status = "INVALID"
@@ -175,6 +205,151 @@ state[0].observation.board = board # shared fields (if marked shared in spec)
175205
state[0].observation.lastOpponentAction = state[1].action
176206
```
177207

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+
178353
## Step 4: Write agents
179354

180355
Agent functions receive `(observation, configuration)` as Struct objects and return an action:
@@ -213,6 +388,8 @@ def test_rewards():
213388

214389

215390
def test_invalid_action():
391+
# Regular envs only. Game Arena envs assert the Step 3b shape instead:
392+
# statuses == ["DONE", "DONE"], rewards == [-1, 1]
216393
env = make("<name>")
217394
env.run([bad_agent, good_agent])
218395
json = env.toJSON()
@@ -241,10 +418,22 @@ Follow the `create-visualizer` skill to build a web-based replay visualizer for
241418

242419
## Checklist
243420

421+
- [ ] Asked the user whether this is a Game Arena or regular environment (Step 0)
244422
- [ ] `<name>.json` spec is valid JSON with all required top-level keys
245423
- [ ] `<name>.py` exports `specification`, `interpreter`, `renderer`, `html_renderer`
246424
- [ ] Interpreter handles: normal play, invalid actions, game-over conditions
247425
- [ ] Rewards are set correctly for all outcomes (win/lose/draw/invalid)
248426
- [ ] `__init__.py` exists (can be empty)
249427
- [ ] Tests cover: normal completion, rewards, invalid actions, renderer output
250428
- [ ] `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

kaggle_environments/envs/word_art/visualizer/default/src/renderer.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ export function renderer(options: RendererOptions) {
122122
const blueAttemptsUsed: number = obs0.blue_attempts_used ?? 0;
123123
const yellowAttemptsUsed: number = obs0.yellow_attempts_used ?? 0;
124124

125-
const isDone = currentStep?.every?.((p: any) => p?.status === 'DONE') ?? false;
125+
// A crashed or timed-out seat voids the episode: every seat ends ERROR
126+
// (a timed-out one keeps TIMEOUT) and all rewards are nulled. Those steps
127+
// are terminal too, so fold them into isDone or the final panel never
128+
// renders and the view sticks on a half-played round.
129+
const isVoided = currentStep?.some?.((p: any) => p?.status === 'ERROR' || p?.status === 'TIMEOUT') ?? false;
130+
const isDone = (currentStep?.every?.((p: any) => p?.status === 'DONE') ?? false) || isVoided;
126131

127132
// Detect a round-transition step: on the sub-step where both teams
128133
// finish their guesses, the env immediately clears the round state and
@@ -422,7 +427,10 @@ export function renderer(options: RendererOptions) {
422427
statusBar.className = 'wa-status-bar sketched-border';
423428
if (isDone) {
424429
let outcome: string;
425-
if (blueScore > yellowScore) outcome = `Blue wins ${blueScore}${yellowScore}!`;
430+
if (isVoided) {
431+
const failed = currentStep.find((p: any) => p?.status === 'ERROR' || p?.status === 'TIMEOUT');
432+
outcome = `Episode voided — an agent ${failed?.status === 'TIMEOUT' ? 'timed out' : 'crashed'}`;
433+
} else if (blueScore > yellowScore) outcome = `Blue wins ${blueScore}${yellowScore}!`;
426434
else if (yellowScore > blueScore) outcome = `Yellow wins ${yellowScore}${blueScore}!`;
427435
else outcome = `Tie ${blueScore}${yellowScore}`;
428436
const final = document.createElement('span');

kaggle_environments/envs/word_art/word_art.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "word_art",
33
"title": "Word Art",
44
"description": "A 2v2 cooperative-vs-competitive game. Each round a secret word is shown to one artist per team; that artist draws ASCII art and passes it to their teammate, who has up to max_attempts guesses. Point value awarded on a correct guess is looked up in guess_points by attempt index (element 0 = attempt 1, element 1 = attempt 2, ...); failing all attempts scores 0. After N rounds the higher total wins. The artist and guesser within each team swap every round.",
5-
"version": "1.1.0",
5+
"version": "1.2.0",
66
"agents": [4],
77
"configuration": {
88
"num_rounds": {

kaggle_environments/envs/word_art/word_art.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,10 @@ def initialize_game(state, env):
583583
env.word_art_state = _WordArtState(sampled)
584584

585585

586-
# Statuses the kaggle framework sets when an agent crashes or times out.
587-
# These end the episode -- see _abort_on_agent_failure.
588-
_TERMINAL_FAILURE_STATUSES = ("TIMEOUT", "ERROR", "INVALID")
586+
# Statuses core.py sets when an agent crashes or times out, as opposed to
587+
# submitting a well-formed-but-illegal action. These void the episode --
588+
# see _abort_on_agent_failure.
589+
_FRAMEWORK_FAILURE_STATUSES = ("TIMEOUT", "ERROR")
589590

590591

591592
def _abort_on_agent_failure(state):
@@ -596,20 +597,36 @@ def _abort_on_agent_failure(state):
596597
making a bad move, and word_art cannot score a 2v2 game around one. The
597598
alternative -- skipping that seat and playing on -- yields an episode
598599
where one model contributes no art and no guesses for the rest of the
599-
game while the scoreboard still reports a winner. Ending here keeps the
600-
failure loud: the offending seat retains its status, so core.py nulls its
601-
reward and the replay shows an errored episode rather than a lopsided one.
600+
game while the scoreboard still reports a winner.
601+
602+
Two distinct outcomes, matching open_spiel_env's non-strict path:
603+
604+
* TIMEOUT/ERROR voids the episode. Every seat is forced to ERROR (a
605+
TIMEOUT seat keeps TIMEOUT, which voids it the same way) so core.py
606+
nulls all four rewards and the replay reads as an errored episode
607+
rather than a decided one.
608+
* INVALID -- a well-formed action that broke the action schema -- ends
609+
the episode as a normal completion: all seats DONE, keeping whatever
610+
round points each team had already banked. Unlike open_spiel, the
611+
accumulated score is preserved rather than overwritten with a flat
612+
+/-1, because word_art's reward channel is a running point total and
613+
discarding it would throw away every completed round.
602614
603615
Note this is deliberately NOT the illegalMoveForfeit path. A model that
604616
answers unparseably forfeits the turn and plays on; a model whose agent
605617
raised has no working turn to fall back to.
606618
"""
607-
if not any(s.status in _TERMINAL_FAILURE_STATUSES for s in state):
608-
return False
609-
for s in state:
610-
if s.status not in _TERMINAL_FAILURE_STATUSES:
619+
if any(s.status in _FRAMEWORK_FAILURE_STATUSES for s in state):
620+
for s in state:
621+
if s.status != "TIMEOUT":
622+
s.status = "ERROR"
623+
return True
624+
if any(s.status == "INVALID" for s in state):
625+
for s in state:
611626
s.status = "DONE"
612-
return True
627+
s.reward = s.reward or 0
628+
return True
629+
return False
613630

614631

615632
def _set_art_statuses(state, round_idx):

kaggle_environments/envs/word_association/visualizer/default/src/renderer.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,10 +591,26 @@ export const GameRenderer: React.FC<GameRendererProps> = (options: GameRendererP
591591
}
592592
}, [step]);
593593

594-
const isGameOver = currentEnvStep[0].status === 'DONE';
594+
// A crashed or timed-out seat ends the episode with ERROR/TIMEOUT rather
595+
// than DONE, so the terminal check must cover all three or the results
596+
// panel silently never renders on a voided episode.
597+
const isVoided = currentEnvStep.some((p: any) => p?.status === 'ERROR' || p?.status === 'TIMEOUT');
598+
const isGameOver = currentEnvStep[0].status === 'DONE' || isVoided;
595599
let winnerText: React.ReactNode = null;
596600

597-
if (isGameOver) {
601+
if (isVoided) {
602+
// Rewards are nulled on a voided episode, so there is no winner to show.
603+
const failedSeat = currentEnvStep.findIndex((p: any) => p?.status === 'ERROR' || p?.status === 'TIMEOUT');
604+
const reason = currentEnvStep[failedSeat]?.status === 'TIMEOUT' ? 'timed out' : 'crashed';
605+
winnerText = (
606+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', lineHeight: '1.2' }}>
607+
<span style={{ color: '#ff6b6b' }}>⚠ EPISODE VOIDED ⚠</span>
608+
<span style={{ fontSize: '14px', color: '#aaaaaa', marginTop: '4px', fontWeight: 'normal' }}>
609+
(An agent {reason} — no result recorded)
610+
</span>
611+
</div>
612+
);
613+
} else if (isGameOver) {
598614
const trapIndex = renderState.roles.findIndex((role) => role === 'trap');
599615
const trapRevealed = trapIndex !== -1 && renderState.revealed[trapIndex];
600616

kaggle_environments/envs/word_association/word_association.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "word_association",
33
"title": "Word Association",
44
"description": "A game of word association and deduction, adapted for AI agents.",
5-
"version": "1.0.0",
5+
"version": "1.1.0",
66
"agents": [4],
77
"configuration": {
88
"board_size": {

0 commit comments

Comments
 (0)