iTheresaApocalypse commited on
Commit
893fd86
·
verified ·
1 Parent(s): 216ed2e

Single <think>-prefilled prompt format, no-reasoning mode, full Doom example; drop benchmark presets

Browse files
README.md CHANGED
@@ -105,10 +105,10 @@ THUSI-Lab/GameScaling
105
  │ └── 27b_s3_1600h_gs7020/
106
  ├── prompts/
107
  │ ├── system_prompt_template.txt standard prompt, 3 slots (§2)
108
- │ └── example_doom_battle1.txt filled-in example
109
  ├── gamescaling/ inference client
110
  │ ├── prompt.py system prompt builders
111
- │ ├── policy.py client, presets, request body
112
  │ ├── action.py action format and parser
113
  │ └── keymaps.py key names <-> browser keys
114
  ├── scripts/serve_vllm.sh start a vLLM server
@@ -162,145 +162,185 @@ Things to keep as shipped:
162
  - **One server per GPU group.** One vLLM replica can serve several games at once, but long
163
  prefills (one image per history turn) make each step slower when many games share it.
164
 
165
- ## 2. System prompt template
166
 
167
  `prompts/system_prompt_template.txt` is the standard system prompt. Fill in three placeholders
168
- and keep everything else **exactly as written**: the model decides its output tags from the
169
- exact text of the Output Format section.
170
 
171
- | Placeholder | What to write | Example |
172
- |---|---|---|
173
- | `{GAME_NAME}` | The game's name. It appears twice: in the opening and the key-list heading. | `Doom Battle-1` |
174
- | `{GAME_RULES}` | One English paragraph: what the game is, the goal, and how it is scored. | `Kill as many monsters as possible; collect health and ammo.` |
175
- | `{KEY_BINDINGS}` | One `- <key> -> <effect>` line per key. Use training key names (§5). | see below |
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- Example `{KEY_BINDINGS}`:
 
178
 
179
  ```text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  - W -> Move forward
181
- - S -> Move backward
182
  - A -> Strafe left
 
183
  - D -> Strafe right
184
- - LB -> Fire
 
 
 
 
 
 
185
  - Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  ```
187
 
188
- Add the last line only for games where the mouse controls the camera, and copy it verbatim. Key
189
- names: uppercase letters (`W`); digits as `zero`..`nine`; F1-F12 as `One`..`Twelve`; mouse
190
- buttons as `LB` / `RB` / `MB`; arrows as `Up` / `Down` / `Left` / `Right`. A complete filled-in
191
- prompt is in `prompts/example_doom_battle1.txt`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- Using the text file directly:
194
 
195
  ```python
196
  tpl = open("prompts/system_prompt_template.txt").read().rstrip("\n")
197
  system = (tpl.replace("{GAME_NAME}", "Doom Battle-1")
198
- .replace("{GAME_RULES}", "Kill as many monsters as possible; collect health and ammo.")
199
- .replace("{KEY_BINDINGS}", "- W -> Move forward\n- LB -> Fire\n"
200
- "- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"))
201
  ```
202
 
203
- The result is byte-identical to what `build_system_prompt(...)` below produces;
204
- `tests/test_core.py` checks this.
205
-
206
  ## 3. Minimal usage
207
 
208
  ```python
209
  from PIL import Image
210
- from gamescaling import GameScalingPolicy, PolicyConfig, build_system_prompt
211
 
212
- system = build_system_prompt(
213
- "Doom Battle-1",
214
- rules="Kill as many monsters as possible; collect health and ammo.",
215
- key_bindings={"W": "Move forward", "S": "Move backward", "A": "Strafe left",
216
- "D": "Strafe right", "LB": "Fire"},
217
- mouse_aim=True, # the mouse controls the camera
218
- ) # reasoning=True by default, matching the <think> prefill
219
- policy = GameScalingPolicy(system, PolicyConfig()) # prefills <think>; frame sent at native size
220
  policy.reset() # call at the start of every episode
221
  decision = policy.act(Image.open("frame.png")) # one call = one 200 ms decision
222
  a = decision.action
223
  a.mouse # (X, Y, Z): mouse movement for this chunk (per-mille of the screen) + scroll
224
  a.steps # 6 groups: the keys held during each 33 ms step
225
  a.parsed # False = could not be parsed; execute as a no-op
 
226
  ```
227
 
228
  Pass `a` to your executor. Run 6 steps of 33 ms, holding that step's keys during each one. Keys
229
  that appear in two consecutive groups stay held rather than being pressed again. Mouse movement
230
  is `X/1000 * screen width` and `Y/1000 * screen height`. You can spread it over the 200 ms or
231
- send it at once.
232
-
233
- Defaults are not tied to any benchmark: any game frame, the standard system prompt, and a
234
- `<think>` prefill. To reproduce a paper table, use
235
- `PolicyConfig.preset("omnigamearena" | "vizdoom" | "gameworld")` instead (§6).
236
- `TRAINING_SYSTEM_PROMPT` is the generic pre-training prompt. Its Output Format is action-only and
237
- does not match a `<think>` prefill, so use it only for ablations.
238
-
239
- ## 4. Prompt and message structure
240
 
241
- Every benchmark uses the same skeleton in a fixed order (`gamescaling/prompt.py`):
242
 
243
- 1. **Opening**: gaming expert + game name + the contract "plan the next 200 ms as 6 steps of
244
- 33 ms";
245
- 2. `**Game Rules**`: what the game is and how it is scored;
246
- 3. **Controls**: the keys the game uses, **written in the training vocabulary** (§5);
247
- 4. `**Task Instruction**`: GameWorld only (one game can have several tasks);
248
- 5. `**Output Format**`: how the model picks its output format;
249
- 6. `**Explanation**` + `**Key Naming Rules**`.
250
 
251
- There are two layouts. They differ only in section 3 and the separators:
252
-
253
- - `build_system_prompt(...)`: key list (`**Common Keys in <game>**` + `- W -> Move forward`).
254
- Used for ViZDoom, OmniGameArena and **any new game**. Add `mouse_aim=True` for games where the
255
- mouse controls the camera.
256
- - `build_gameworld_system_prompt(...)`: free-text paragraphs (`**Role and Controls**`), for
257
- games that come with their own written controls, as in GameWorld. `translate_controls`
258
- rewrites browser key names into the training vocabulary (`ArrowLeft` -> `Left`, `a/d` ->
259
- `A/D`, `1-9:` -> `one-nine:`).
260
-
261
- The Output Format **must match whether the client prefills `<think>`**:
262
-
263
- | `reasoning` | Client | Model output |
264
  |---|---|---|
265
- | `True` (default) | `think_prefill=True`: the assistant turn starts with `<think>` | Decides whether and how long to think, closes `</think>`, then outputs the action |
266
- | `False` | no prefill | Outputs `<|action_start|>...<|action_end|>` directly |
 
 
 
267
 
268
  Each user turn is the text `current game screen` followed by the frame. Multi-turn messages
269
  (`policy.py`):
270
 
271
- ```
272
- system system prompt
273
  user "current game screen" + frame t-k ┐ last history_len turns (default 5)
274
  assistant action taken at t-k (no reasoning) ┘
275
  ...
276
  user "current game screen" + current frame
277
- assistant "<think>" (prefill; continue_final_message=True)
278
  ```
279
 
280
- Past turns keep only the action (`history_think=0`, as in the paper). A turn that failed to parse
281
- is stored as an explicit no-op, `<|action_start|>0 0 0 ; ; ; ; ; ; <|action_end|>`, not as the
282
- raw bad output.
283
 
284
- Default request: `temperature=1.0`, `max_tokens=2048`, `stop=["<|action_end|>"]`,
285
  `skip_special_tokens=false`, JPEG frames (quality 90) at native resolution.
286
 
287
  ## 5. Action format
288
 
289
- ```
290
  <|action_start|>X Y Z ; g1 ; g2 ; g3 ; g4 ; g5 ; g6<|action_end|>
291
  ```
292
 
293
  - `X Y`: mouse movement over the whole 200 ms, in per-mille of the screen
294
  (`X = Σdx / screen width × 1000`), clipped to ±1000. X>0 is right, Y>0 is down.
295
  - `Z`: scroll notches, clipped to ±5. Z>0 is up.
296
- - `g1..g6`: six 33 ms steps. Each group lists the keys **held** during that step, at most 4. An
297
  empty group releases all keys, but the `;` must stay.
298
  - `<|action_start|>` / `<|action_end|>` are special tokens. Requests must set
299
  `skip_special_tokens: false` (`build_request` does), or the server strips the tags.
300
  `parse_action` also accepts a bare `X Y Z ; ...` as a fallback.
301
- - The action is read **only after the last `</think>`**: a model that thinks often quotes the
302
- format inside its reasoning. An unclosed `<think>` means the generation was cut off; it is
303
- executed as a no-op (`Action.truncated=True`).
304
 
305
  Training key vocabulary (74 tokens, case-sensitive, `gamescaling.action.KEY_VOCAB`):
306
 
@@ -308,64 +348,34 @@ Training key vocabulary (74 tokens, case-sensitive, `gamescaling.action.KEY_VOCA
308
  |---|---|
309
  | Letters | `A`..`Z` |
310
  | Number row | `zero` `one` .. `nine` |
311
- | F1-F12 | `One` `Two` .. `Twelve` (**capitalized** = function key) |
312
  | Mouse buttons | `LB` `RB` `MB` |
313
  | Arrows | `Up` `Down` `Left` `Right` |
314
  | Other | `Shift` `Ctrl` `Alt` `Tab` `Caps` `Esc` `Space` `Enter` `Back` `Delete` `Insert` `Home` `End` `Pause` `Equal` `Minus` `Period` `Slash` `Quote` |
315
 
316
  Tokens outside the vocabulary are dropped and recorded in `Action.dropped`.
317
- `keymaps.TO_PLAYWRIGHT` maps them to browser key names.
318
-
319
- ## 6. Evaluation
320
-
321
- The paper evaluates on four benchmarks: MCU (Minecraft), ViZDoom, OmniGameArena and GameWorld.
322
- To get comparable numbers, keep these settings fixed.
323
-
324
- **Clock.** The standard setting is **time-paused**: the game freezes while the model thinks and
325
- advances only while a chunk executes. In a real-time setting the game keeps running during
326
- inference, so latency becomes part of the score. Do not compare scores across clocks.
327
-
328
- **Client presets.** Each benchmark has a preset in `PolicyConfig.preset(...)`:
329
-
330
- | Preset | Frame | `<think>` prefill | Other |
331
- |---|---|---|---|
332
- | `omnigamearena` | resized to 854×480, JPEG | yes | 300 steps per episode (500 for `scene_escape` and `cue_chase`), 3 episodes |
333
- | `vizdoom` | resized to 854×480, JPEG | yes | no stop string; 4 maps × 10 episodes, at most 350 chunks each |
334
- | `gameworld` | height 480, PNG (Lanczos) | no | `max_tokens=4096`, lenient parsing; use `build_gameworld_system_prompt(..., reasoning=False)` |
335
-
336
- All presets keep 5 history turns (frame + action, no reasoning) at temperature 1.0. Run at least
337
- 3 episodes per game; single episodes vary a lot.
338
-
339
- **Checklist before reading a score:**
340
-
341
- - [ ] The system prompt is the template from §2 (or `build_system_prompt`), with
342
- `reasoning=True` when the client prefills `<think>`, and `reasoning=False` for `gameworld`.
343
- - [ ] The parse rate is high. `Decision.action.parsed` is `False` for unparseable replies, and
344
- `Action.truncated` is `True` when `<think>` was never closed. Many of either usually means a
345
- wrong template, a stripped special token, or a too-small `max_tokens`.
346
- - [ ] Executed actions actually move the game. Each chunk runs in wall-clock time (6 × 33 ms), so
347
- a slow or shared renderer makes held keys barely move, with no error message. For
348
- OmniGameArena, run **one UE5 instance per GPU**.
349
- - [ ] Episodes are real. In OmniGameArena, start a fresh UE5 process for every `solo_craft`
350
- episode; a reused instance ends episodes 2 and 3 immediately and repeats the first score.
351
-
352
- **Scoring.** On OmniGameArena, `solo_craft` is scored as `min(V/50, 1)` from the raw points `V`;
353
- the overall score is the mean over the seven games. ViZDoom counts kills on the battle maps and
354
- kills minus deaths on the defend maps.
355
-
356
- ## 7. Adding a new game
357
-
358
- 1. Fill in `prompts/system_prompt_template.txt` (§2), or call `build_system_prompt` with the game
359
- name, rules and `key_bindings` in the training vocabulary. Add `mouse_aim=True` if the mouse
360
- controls the camera.
361
- 2. Capture one frame every 200 ms (or at every decision under a paused clock) and call
362
- `policy.act(frame)`.
363
- 3. Execute the returned `Action` as in §5: held keys for 6 steps of 33 ms, plus mouse movement and
364
- scroll.
365
- 4. Keep the default client (native frame size, `<think>` prefill, standard prompt). Switch to a
366
- preset (§6) only to match a paper table.
367
-
368
- ## 8. Tests
369
 
370
  ```bash
371
  python tests/test_core.py # or: python -m pytest tests/
 
105
  │ └── 27b_s3_1600h_gs7020/
106
  ├── prompts/
107
  │ ├── system_prompt_template.txt standard prompt, 3 slots (§2)
108
+ │ └── example_doom_battle1.txt complete Doom prompt (§2)
109
  ├── gamescaling/ inference client
110
  │ ├── prompt.py system prompt builders
111
+ │ ├── policy.py client and request body
112
  │ ├── action.py action format and parser
113
  │ └── keymaps.py key names <-> browser keys
114
  ├── scripts/serve_vllm.sh start a vLLM server
 
162
  - **One server per GPU group.** One vLLM replica can serve several games at once, but long
163
  prefills (one image per history turn) make each step slower when many games share it.
164
 
165
+ ## 2. System prompt
166
 
167
  `prompts/system_prompt_template.txt` is the standard system prompt. Fill in three placeholders
168
+ and keep everything else exactly as written: the model decides its output tags from the exact
169
+ text of the Output Format section.
170
 
171
+ | Placeholder | What to write |
172
+ |---|---|
173
+ | `{GAME_NAME}` | The game's name. It appears twice: in the opening and in the key-list heading. |
174
+ | `{GAME_RULES}` | What the game is, what is on screen, the goal and how it is scored. Plain text; several lines are fine. |
175
+ | `{KEY_BINDINGS}` | One `- <key> -> <effect>` line per key, in training key names (§5). End with the mouse line below if the mouse turns the camera. |
176
+
177
+ The mouse line, copied verbatim:
178
+
179
+ ```text
180
+ - Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
181
+ ```
182
+
183
+ > **Note.** Lines such as `**Game Rules**`, `**Output Format**` and
184
+ > `**Explanation**` are plain-text section headers that the model saw in training. They are part
185
+ > of the prompt, not Markdown formatting: send them to the model unchanged, including the
186
+ > asterisks.
187
+
188
+ ### Complete example: Doom Battle-1
189
 
190
+ This is the full system prompt for the ViZDoom Battle-1 map, as in
191
+ `prompts/example_doom_battle1.txt`. It is the same text in both reasoning modes (§4).
192
 
193
  ```text
194
+ You are a gaming expert, and you are currently playing Doom Battle-1. You are proficient in keyboard and mouse operations. You understand game mechanics and combat pacing, can quickly extract key information from the current screen, and can think and make precise decisions at critical moments. Based on the current screen, plan the next 200ms of actions, consisting of 6 steps spaced 33ms apart. Each step lasts 33ms, until the next step begins. If the current situation continues the previous strategy, output the action directly. Think only when the situation changes significantly, the previous analysis is no longer valid, or a new objective appears.
195
+ **Game Rules**
196
+ You are playing Doom, a first-person shooter, in an arena full of monsters. Survive and kill as many monsters as you can.
197
+
198
+ ## What you see on screen
199
+ - First-person view.
200
+ - The crosshair at screen centre marks where shots land.
201
+ - The status bar along the bottom shows HEALTH on the left and AMMO on the right.
202
+ - Monsters keep arriving for the whole episode; some throw fireballs from range.
203
+
204
+ ## Rules
205
+ - Score is the number of monsters you kill in the episode.
206
+ - Firing costs ammo, and the episode ends as soon as your health reaches 0, so dodging matters as much as shooting: standing still in the open is how most episodes end early.
207
+ - Health packs and ammo boxes lie around the map; walk over one to pick it up. Running out of either is fatal, so collect them while fighting.
208
+ - A target is hit when it is under the crosshair, so turn with the mouse until the monster is centred before firing.
209
+ - The map is one large open arena, so monsters can close in from any direction.
210
+ **Common Keys in Doom Battle-1**
211
  - W -> Move forward
 
212
  - A -> Strafe left
213
+ - S -> Move backward
214
  - D -> Strafe right
215
+ - LB -> Fire weapon
216
+ - Ctrl -> Fire weapon (keyboard alternative)
217
+ - Shift -> Run
218
+ - Space -> Open doors / use switches
219
+ - one -> Select weapon slot 1
220
+ - two -> Select weapon slot 2
221
+ - three -> Select weapon slot 3
222
  - Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
223
+ **Output Format**
224
+ **Action only (usual)**
225
+ <think></think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>
226
+ **Thinking + action (when necessary)**
227
+ <think>reasoning</think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>
228
+ **Explanation**
229
+ 1. **Mouse Movement**: First, specify the relative displacement X, Y (X>0 means move right, Y>0 means move down) and scroll amount Z (Z>0 means scroll up).
230
+ 2. **Key Sequence**: Then list 6 groups of keys; within each group, keys are separated by spaces, and groups are separated by semicolons.
231
+ - Each group can contain up to 4 keys.
232
+ - If a group has no keys, leave it empty but keep the `;`.
233
+ 3. Only output a plain string that conforms to the above format — no line breaks and no quotation marks.
234
+ **Key Naming Rules**
235
+ - Number keys `0-9`: use lowercase English words, e.g., `zero` for `0`, `one` for `1`, ... `nine` for `9`.
236
+ - Function keys `F1-F12`: use capitalized English words, e.g., `One` represents `F1`, `Two` represents `F2`, and so on.
237
+ - Mouse buttons: use `LB`, `RB`, `MB` for the left, right, and middle mouse button.
238
+ - Letters: use the uppercase letter, e.g., `A`, `D`, `W`, `S`.
239
+ - Arrow keys: use `Up`, `Down`, `Left`, `Right`.
240
+ - Modifier/special keys: `Shift`, `Ctrl`, `Alt`, `Tab`, `Caps`, `Esc`, `Space`, `Enter`, `Back` (backspace), `Delete`, `Insert`, `Home`, `End`, `Pause`.
241
  ```
242
 
243
+ The same prompt from code (`tests/test_core.py` checks that it matches the file byte for byte):
244
+
245
+ ```python
246
+ from gamescaling import build_system_prompt
247
+
248
+ DOOM_RULES = open("prompts/example_doom_battle1.txt").read() \
249
+ .split("**Game Rules**\n", 1)[1].split("\n**Common Keys in", 1)[0]
250
+
251
+ system = build_system_prompt(
252
+ "Doom Battle-1",
253
+ rules=DOOM_RULES,
254
+ key_bindings=[
255
+ ("W", "Move forward"), ("A", "Strafe left"), ("S", "Move backward"), ("D", "Strafe right"),
256
+ ("LB", "Fire weapon"), ("Ctrl", "Fire weapon (keyboard alternative)"), ("Shift", "Run"),
257
+ ("Space", "Open doors / use switches"), ("one", "Select weapon slot 1"),
258
+ ("two", "Select weapon slot 2"), ("three", "Select weapon slot 3"),
259
+ ],
260
+ mouse_aim=True, # appends the "Mouse X Y -> ..." line
261
+ )
262
+ ```
263
 
264
+ Filling the text file directly gives the same result:
265
 
266
  ```python
267
  tpl = open("prompts/system_prompt_template.txt").read().rstrip("\n")
268
  system = (tpl.replace("{GAME_NAME}", "Doom Battle-1")
269
+ .replace("{GAME_RULES}", DOOM_RULES)
270
+ .replace("{KEY_BINDINGS}", "- W -> Move forward\n- A -> Strafe left\n..."
271
+ "\n- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"))
272
  ```
273
 
 
 
 
274
  ## 3. Minimal usage
275
 
276
  ```python
277
  from PIL import Image
278
+ from gamescaling import GameScalingPolicy, PolicyConfig
279
 
280
+ policy = GameScalingPolicy(system, PolicyConfig()) # system prompt from §2; prefills <think>
 
 
 
 
 
 
 
281
  policy.reset() # call at the start of every episode
282
  decision = policy.act(Image.open("frame.png")) # one call = one 200 ms decision
283
  a = decision.action
284
  a.mouse # (X, Y, Z): mouse movement for this chunk (per-mille of the screen) + scroll
285
  a.steps # 6 groups: the keys held during each 33 ms step
286
  a.parsed # False = could not be parsed; execute as a no-op
287
+ decision.reasoning # the model's thinking for this step ("" if it acted directly)
288
  ```
289
 
290
  Pass `a` to your executor. Run 6 steps of 33 ms, holding that step's keys during each one. Keys
291
  that appear in two consecutive groups stay held rather than being pressed again. Mouse movement
292
  is `X/1000 * screen width` and `Y/1000 * screen height`. You can spread it over the 200 ms or
293
+ send it at once. The frame is sent at its native size; set `PolicyConfig(image_size=(w, h))` to
294
+ resize it first.
 
 
 
 
 
 
 
295
 
296
+ ## 4. Reasoning modes and message structure
297
 
298
+ The assistant turn is always prefilled, and the request continues it
299
+ (`continue_final_message=True`). The system prompt is the same in both modes.
 
 
 
 
 
300
 
301
+ | Mode | Prefill | What the model does |
 
 
 
 
 
 
 
 
 
 
 
 
302
  |---|---|---|
303
+ | `PolicyConfig()` (default, `reasoning=True`) | `<think>` | Decides whether and how long to think, closes `</think>`, then outputs the action |
304
+ | `PolicyConfig(reasoning=False)` | `<think>\n\n</think>` | Thinking is closed and empty; the model outputs `<|action_start|>...<|action_end|>` directly |
305
+
306
+ `<think>\n\n</think>` is exactly how the chat template renders a turn without reasoning, so the
307
+ no-reasoning mode stays in the training format. Do not send the request without a prefill.
308
 
309
  Each user turn is the text `current game screen` followed by the frame. Multi-turn messages
310
  (`policy.py`):
311
 
312
+ ```text
313
+ system system prompt (§2)
314
  user "current game screen" + frame t-k ┐ last history_len turns (default 5)
315
  assistant action taken at t-k (no reasoning) ┘
316
  ...
317
  user "current game screen" + current frame
318
+ assistant "<think>" or "<think>\n\n</think>" (prefill, continued by the model)
319
  ```
320
 
321
+ Past turns keep only the action (`history_think=0`). A turn that failed to parse is stored as an
322
+ explicit no-op, `<|action_start|>0 0 0 ; ; ; ; ; ; <|action_end|>`, not as the raw bad output.
 
323
 
324
+ Client defaults: `temperature=1.0`, `max_tokens=2048`, `stop=["<|action_end|>"]`,
325
  `skip_special_tokens=false`, JPEG frames (quality 90) at native resolution.
326
 
327
  ## 5. Action format
328
 
329
+ ```text
330
  <|action_start|>X Y Z ; g1 ; g2 ; g3 ; g4 ; g5 ; g6<|action_end|>
331
  ```
332
 
333
  - `X Y`: mouse movement over the whole 200 ms, in per-mille of the screen
334
  (`X = Σdx / screen width × 1000`), clipped to ±1000. X>0 is right, Y>0 is down.
335
  - `Z`: scroll notches, clipped to ±5. Z>0 is up.
336
+ - `g1..g6`: six 33 ms steps. Each group lists the keys held during that step, at most 4. An
337
  empty group releases all keys, but the `;` must stay.
338
  - `<|action_start|>` / `<|action_end|>` are special tokens. Requests must set
339
  `skip_special_tokens: false` (`build_request` does), or the server strips the tags.
340
  `parse_action` also accepts a bare `X Y Z ; ...` as a fallback.
341
+ - The action is read only after the last `</think>`: a model that thinks often quotes the format
342
+ inside its reasoning. An unclosed `<think>` means the generation was cut off; it is executed as
343
+ a no-op (`Action.truncated=True`).
344
 
345
  Training key vocabulary (74 tokens, case-sensitive, `gamescaling.action.KEY_VOCAB`):
346
 
 
348
  |---|---|
349
  | Letters | `A`..`Z` |
350
  | Number row | `zero` `one` .. `nine` |
351
+ | F1-F12 | `One` `Two` .. `Twelve` (capitalized = function key) |
352
  | Mouse buttons | `LB` `RB` `MB` |
353
  | Arrows | `Up` `Down` `Left` `Right` |
354
  | Other | `Shift` `Ctrl` `Alt` `Tab` `Caps` `Esc` `Space` `Enter` `Back` `Delete` `Insert` `Home` `End` `Pause` `Equal` `Minus` `Period` `Slash` `Quote` |
355
 
356
  Tokens outside the vocabulary are dropped and recorded in `Action.dropped`.
357
+ `keymaps.TO_PLAYWRIGHT` and `keymaps.TO_UE5` map them to browser and Unreal Engine key names;
358
+ `translate_controls` rewrites browser key names in a text into training tokens.
359
+
360
+ ## 6. Running it in a game
361
+
362
+ 1. Write the system prompt for your game (§2).
363
+ 2. Capture the current frame and call `policy.act(frame)` once per decision.
364
+ 3. Execute the returned `Action` as in §5: held keys for 6 steps of 33 ms, plus mouse movement
365
+ and scroll.
366
+ 4. Call `policy.reset()` when an episode ends.
367
+
368
+ Before trusting the results, check:
369
+
370
+ - **Parse rate.** `Decision.action.parsed` should almost always be `True`. Many failures, or many
371
+ `Action.truncated`, usually mean a replaced chat template, stripped special tokens, a missing
372
+ prefill, or a too-small `max_tokens`.
373
+ - **Actions take effect.** Each chunk runs in wall-clock time (6 × 33 ms). If the game renders
374
+ slowly, for example several game instances sharing one GPU, held keys barely move the
375
+ character and nothing reports an error. Watch a recording before reading any numbers.
376
+ - **Several episodes.** Single episodes vary a lot at `temperature=1.0`.
377
+
378
+ ## 7. Tests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
  ```bash
381
  python tests/test_core.py # or: python -m pytest tests/
gamescaling/__init__.py CHANGED
@@ -12,11 +12,18 @@ from .action import (
12
  parse_action,
13
  split_reasoning,
14
  )
15
- from .policy import Decision, GameScalingPolicy, PolicyConfig, build_request, encode_image, finalize
 
 
 
 
 
 
 
 
 
16
  from .prompt import (
17
- TRAINING_SYSTEM_PROMPT,
18
  USER_TURN_TEXT,
19
- build_gameworld_system_prompt,
20
  build_system_prompt,
21
  translate_controls,
22
  )
@@ -26,7 +33,7 @@ __version__ = "0.1.0"
26
  __all__ = [
27
  "ACTION_END", "ACTION_START", "CHUNK_MS", "CHUNK_STEPS", "KEY_VOCAB", "NOOP_ACTION_TEXT",
28
  "STEP_MS", "Action", "parse_action", "split_reasoning",
29
- "Decision", "GameScalingPolicy", "PolicyConfig", "build_request", "encode_image", "finalize",
30
- "TRAINING_SYSTEM_PROMPT", "USER_TURN_TEXT", "build_gameworld_system_prompt",
31
- "build_system_prompt", "translate_controls",
32
  ]
 
12
  parse_action,
13
  split_reasoning,
14
  )
15
+ from .policy import (
16
+ NO_THINK_PREFILL,
17
+ THINK_PREFILL,
18
+ Decision,
19
+ GameScalingPolicy,
20
+ PolicyConfig,
21
+ build_request,
22
+ encode_image,
23
+ finalize,
24
+ )
25
  from .prompt import (
 
26
  USER_TURN_TEXT,
 
27
  build_system_prompt,
28
  translate_controls,
29
  )
 
33
  __all__ = [
34
  "ACTION_END", "ACTION_START", "CHUNK_MS", "CHUNK_STEPS", "KEY_VOCAB", "NOOP_ACTION_TEXT",
35
  "STEP_MS", "Action", "parse_action", "split_reasoning",
36
+ "NO_THINK_PREFILL", "THINK_PREFILL", "Decision", "GameScalingPolicy", "PolicyConfig",
37
+ "build_request", "encode_image", "finalize",
38
+ "USER_TURN_TEXT", "build_system_prompt", "translate_controls",
39
  ]
gamescaling/action.py CHANGED
@@ -24,7 +24,6 @@ from dataclasses import dataclass, field
24
 
25
  ACTION_START = "<|action_start|>"
26
  ACTION_END = "<|action_end|>"
27
- ACTION_END_TOKEN_ID = 151670
28
  THINK_OPEN = "<think>"
29
  THINK_CLOSE = "</think>"
30
 
@@ -159,7 +158,7 @@ def _parse_strict(tail: str) -> Action:
159
 
160
 
161
  def _parse_lenient(text: str) -> Action:
162
- """GameWorld's recovery for malformed replies (see `parse_action`)."""
163
  text = text.replace(THINK_OPEN, " ").replace(THINK_CLOSE, " ")
164
  m = _WRAPPED_INT_RE.search(text)
165
  if m:
@@ -197,10 +196,10 @@ def _parse_lenient(text: str) -> Action:
197
  def parse_action(text: str, *, lenient: bool = False) -> Action:
198
  """Raw model reply -> :class:`Action`. Never raises; failures are no-ops.
199
 
200
- ``lenient=False`` (OmniGameArena / ViZDoom protocol): only a chunk after the
201
  last ``</think>`` counts, either tagged or as a bare ``X Y Z ; ...`` body.
202
 
203
- ``lenient=True`` (GameWorld protocol): additionally recover shapes a
204
  well-trained checkpoint rarely emits -- an action inside an unclosed
205
  ``<think>``, a 2-number mouse prefix, a keys-only skeleton -- by locating the
206
  ``;``-separated six-group skeleton anywhere in the reply.
 
24
 
25
  ACTION_START = "<|action_start|>"
26
  ACTION_END = "<|action_end|>"
 
27
  THINK_OPEN = "<think>"
28
  THINK_CLOSE = "</think>"
29
 
 
158
 
159
 
160
  def _parse_lenient(text: str) -> Action:
161
+ """Recovery for malformed replies (see `parse_action`)."""
162
  text = text.replace(THINK_OPEN, " ").replace(THINK_CLOSE, " ")
163
  m = _WRAPPED_INT_RE.search(text)
164
  if m:
 
196
  def parse_action(text: str, *, lenient: bool = False) -> Action:
197
  """Raw model reply -> :class:`Action`. Never raises; failures are no-ops.
198
 
199
+ ``lenient=False`` (default): only a chunk after the
200
  last ``</think>`` counts, either tagged or as a bare ``X Y Z ; ...`` body.
201
 
202
+ ``lenient=True``: additionally recover shapes a
203
  well-trained checkpoint rarely emits -- an action inside an unclosed
204
  ``<think>``, a 2-number mouse prefix, a keys-only skeleton -- by locating the
205
  ``;``-separated six-group skeleton anywhere in the reply.
gamescaling/keymaps.py CHANGED
@@ -10,7 +10,7 @@ from .action import LETTER_KEYS
10
 
11
  MOUSE_BUTTONS: dict[str, str] = {"LB": "left", "RB": "right", "MB": "middle"}
12
 
13
- # Unreal Engine 5 FKey names (OmniGameArena RemoteInput). Left variants are
14
  # used for the L/R-merged modifier tokens.
15
  TO_UE5: dict[str, str] = {
16
  **{c: c for c in LETTER_KEYS},
@@ -28,8 +28,7 @@ TO_UE5: dict[str, str] = {
28
  "Quote": "Apostrophe",
29
  }
30
 
31
- # Playwright `page.keyboard` names (GameWorld). Letters are lower case because
32
- # GameWorld's legal-key lists are written that way.
33
  TO_PLAYWRIGHT: dict[str, str] = {
34
  **{c: c.lower() for c in LETTER_KEYS},
35
  "zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
 
10
 
11
  MOUSE_BUTTONS: dict[str, str] = {"LB": "left", "RB": "right", "MB": "middle"}
12
 
13
+ # Unreal Engine 5 FKey names. Left variants are
14
  # used for the L/R-merged modifier tokens.
15
  TO_UE5: dict[str, str] = {
16
  **{c: c for c in LETTER_KEYS},
 
28
  "Quote": "Apostrophe",
29
  }
30
 
31
+ # Playwright `page.keyboard` names (browser games). Letters are lower case.
 
32
  TO_PLAYWRIGHT: dict[str, str] = {
33
  **{c: c.lower() for c in LETTER_KEYS},
34
  "zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
gamescaling/policy.py CHANGED
@@ -7,7 +7,8 @@ One call to :meth:`GameScalingPolicy.act` is one 200 ms decision:
7
  ... then the action the model took (its reasoning is dropped
8
  unless `history_think` asks to keep it)
9
  user "current game screen" + the current frame
10
- assistant optionally prefilled with "<think>"; the model continues
 
11
 
12
  The checkpoint's own chat template renders a past assistant turn without
13
  reasoning as `<think>\\n\\n</think>\\n\\n<|action_start|>...`, exactly as in
@@ -22,7 +23,7 @@ import logging
22
  import re
23
  import time
24
  from collections import deque
25
- from dataclasses import dataclass, field, replace
26
  from typing import Any, NamedTuple
27
 
28
  import requests
@@ -30,7 +31,6 @@ from PIL import Image
30
 
31
  from .action import (
32
  ACTION_END,
33
- ACTION_END_TOKEN_ID,
34
  ACTION_START,
35
  THINK_OPEN,
36
  Action,
@@ -41,6 +41,11 @@ from .prompt import USER_TURN_TEXT
41
 
42
  logger = logging.getLogger(__name__)
43
 
 
 
 
 
 
44
  _RESAMPLE = {"bilinear": Image.BILINEAR, "lanczos": Image.LANCZOS, "bicubic": Image.BICUBIC}
45
 
46
 
@@ -54,45 +59,24 @@ class PolicyConfig:
54
  # How many of the most recent past turns keep their <think> text; -1 = all.
55
  history_think: int = 0
56
  # Frame sent to the model. None keeps the given game screen as-is.
57
- # An exact (w, h), or image_height, is only for a benchmark preset.
58
  image_size: tuple[int, int] | None = None
59
- image_height: int | None = None
60
  image_format: str = "jpeg" # jpeg | png
61
  jpeg_quality: int = 90
62
  resample: str = "bilinear"
63
- # Prefill "<think>" so the model reasons freely and closes it itself. The
64
- # system prompt must then be built with reasoning=True.
65
- think_prefill: bool = True
66
  temperature: float = 1.0
67
  max_tokens: int = 2048
68
  stop: list[str] = field(default_factory=lambda: [ACTION_END])
69
- stop_token_ids: list[int] = field(default_factory=list)
70
- include_stop_str_in_output: bool = False
71
  lenient_parse: bool = False
72
  timeout: float = 120.0
73
 
74
- @classmethod
75
- def preset(cls, benchmark: str, **overrides: Any) -> "PolicyConfig":
76
- """Paper-benchmark clients. The default constructor is not one of these.
77
-
78
- Default ``PolicyConfig()`` prefills ``<think>`` and sends the given game
79
- frame unchanged. Use a preset only when reproducing a paper table.
80
- """
81
- presets = {
82
- "omnigamearena": cls(image_size=(854, 480)),
83
- # Free-form reasoning without a stop string: a model that quotes the
84
- # format inside <think> would otherwise be cut off mid-thought.
85
- "vizdoom": cls(image_size=(854, 480), stop=[]),
86
- "gameworld": cls(
87
- image_size=None, image_height=480, image_format="png", resample="lanczos",
88
- think_prefill=False, max_tokens=4096, stop=[ACTION_END],
89
- stop_token_ids=[ACTION_END_TOKEN_ID], include_stop_str_in_output=True,
90
- lenient_parse=True,
91
- ),
92
- }
93
- if benchmark not in presets:
94
- raise ValueError(f"unknown benchmark {benchmark!r}; known: {sorted(presets)}")
95
- return replace(presets[benchmark], **overrides)
96
 
97
 
98
  class Turn(NamedTuple):
@@ -119,9 +103,6 @@ def encode_image(image: Image.Image, cfg: PolicyConfig) -> str:
119
  img = image
120
  if cfg.image_size is not None and img.size != tuple(cfg.image_size):
121
  img = img.resize(tuple(cfg.image_size), _RESAMPLE[cfg.resample])
122
- elif cfg.image_height and img.height != cfg.image_height:
123
- width = max(1, round(img.width * cfg.image_height / img.height))
124
- img = img.resize((width, cfg.image_height), _RESAMPLE[cfg.resample])
125
  buf = io.BytesIO()
126
  if cfg.image_format == "png":
127
  img.convert("RGB").save(buf, format="PNG")
@@ -157,14 +138,9 @@ def build_request(messages: list[dict], cfg: PolicyConfig) -> dict:
157
  }
158
  if cfg.stop:
159
  body["stop"] = list(cfg.stop)
160
- if cfg.stop_token_ids:
161
- body["stop_token_ids"] = list(cfg.stop_token_ids)
162
- if cfg.include_stop_str_in_output:
163
- body["include_stop_str_in_output"] = True
164
- if cfg.think_prefill:
165
- body["messages"].append({"role": "assistant", "content": THINK_OPEN})
166
- body["continue_final_message"] = True
167
- body["add_generation_prompt"] = False
168
  return body
169
 
170
 
@@ -175,8 +151,8 @@ def finalize(text: str, finish_reason: str | None, cfg: PolicyConfig) -> str:
175
  from it. Both are restored so the reply parses the same way as training data.
176
  """
177
  text = text or ""
178
- if cfg.think_prefill and not text.lstrip().startswith(THINK_OPEN):
179
- text = THINK_OPEN + text
180
  _, tail, closed = split_reasoning(text)
181
  if closed and ACTION_START not in tail:
182
  bare = tail.strip()
 
7
  ... then the action the model took (its reasoning is dropped
8
  unless `history_think` asks to keep it)
9
  user "current game screen" + the current frame
10
+ assistant prefilled with "<think>" (reasoning=True) or
11
+ "<think>\\n\\n</think>" (reasoning=False); the model continues
12
 
13
  The checkpoint's own chat template renders a past assistant turn without
14
  reasoning as `<think>\\n\\n</think>\\n\\n<|action_start|>...`, exactly as in
 
23
  import re
24
  import time
25
  from collections import deque
26
+ from dataclasses import dataclass, field
27
  from typing import Any, NamedTuple
28
 
29
  import requests
 
31
 
32
  from .action import (
33
  ACTION_END,
 
34
  ACTION_START,
35
  THINK_OPEN,
36
  Action,
 
41
 
42
  logger = logging.getLogger(__name__)
43
 
44
+ # Assistant prefill. Both modes use the same system prompt; the empty think
45
+ # block is exactly how training renders a turn without reasoning.
46
+ THINK_PREFILL = THINK_OPEN
47
+ NO_THINK_PREFILL = "<think>\n\n</think>"
48
+
49
  _RESAMPLE = {"bilinear": Image.BILINEAR, "lanczos": Image.LANCZOS, "bicubic": Image.BICUBIC}
50
 
51
 
 
59
  # How many of the most recent past turns keep their <think> text; -1 = all.
60
  history_think: int = 0
61
  # Frame sent to the model. None keeps the given game screen as-is.
 
62
  image_size: tuple[int, int] | None = None
 
63
  image_format: str = "jpeg" # jpeg | png
64
  jpeg_quality: int = 90
65
  resample: str = "bilinear"
66
+ # True: prefill "<think>" and let the model decide whether and how long to
67
+ # think. False: prefill an empty think block so the model acts directly.
68
+ reasoning: bool = True
69
  temperature: float = 1.0
70
  max_tokens: int = 2048
71
  stop: list[str] = field(default_factory=lambda: [ACTION_END])
72
+ # Also recover malformed replies (an action inside an unclosed <think>, a
73
+ # keys-only skeleton). Off by default.
74
  lenient_parse: bool = False
75
  timeout: float = 120.0
76
 
77
+ @property
78
+ def prefill(self) -> str:
79
+ return THINK_PREFILL if self.reasoning else NO_THINK_PREFILL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
 
82
  class Turn(NamedTuple):
 
103
  img = image
104
  if cfg.image_size is not None and img.size != tuple(cfg.image_size):
105
  img = img.resize(tuple(cfg.image_size), _RESAMPLE[cfg.resample])
 
 
 
106
  buf = io.BytesIO()
107
  if cfg.image_format == "png":
108
  img.convert("RGB").save(buf, format="PNG")
 
138
  }
139
  if cfg.stop:
140
  body["stop"] = list(cfg.stop)
141
+ body["messages"].append({"role": "assistant", "content": cfg.prefill})
142
+ body["continue_final_message"] = True
143
+ body["add_generation_prompt"] = False
 
 
 
 
 
144
  return body
145
 
146
 
 
151
  from it. Both are restored so the reply parses the same way as training data.
152
  """
153
  text = text or ""
154
+ if not text.lstrip().startswith(THINK_OPEN):
155
+ text = cfg.prefill + text
156
  _, tail, closed = split_reasoning(text)
157
  if closed and ACTION_START not in tail:
158
  bare = tail.strip()
gamescaling/prompt.py CHANGED
@@ -1,18 +1,16 @@
1
- """System prompts for GameScaling checkpoints.
2
 
3
- All three benchmarks in the paper use the same skeleton, in this order:
4
 
5
  1. opening: gaming expert, the game's name, the 200 ms / 6 x 33 ms contract;
6
  2. ``**Game Rules**``: what the game is and how it is scored;
7
- 3. controls: the keys this game uses, written in the training vocabulary;
8
- 4. ``**Task Instruction**`` (GameWorld only: one game has several tasks);
9
- 5. ``**Output Format**``: plain, or the empty-think / think+action variant when
10
- the client lets the model reason;
11
- 6. ``**Explanation**`` + ``**Key Naming Rules**``.
12
-
13
- Only the wording of the controls section and the separators differ between the
14
- two layouts below. Copy the strings, do not paraphrase them: the checkpoints key
15
- their output tag off the exact Output Format section.
16
  """
17
 
18
  from __future__ import annotations
@@ -34,10 +32,7 @@ OPENING = (
34
  "analysis is no longer valid, or a new objective appears."
35
  )
36
 
37
- OUTPUT_FORMAT_PLAIN = (
38
- "<|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>"
39
- )
40
- OUTPUT_FORMAT_THINK = (
41
  "**Action only (usual)**\n"
42
  "<think></think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10"
43
  "<|action_end|>\n"
@@ -71,47 +66,23 @@ TAIL = (
71
  MOUSE_AIM_LINE = "- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"
72
  _MOUSE_BUTTON_ALIASES = {"LMB": "LB", "RMB": "RB", "MMB": "MB"}
73
 
74
- # The generic prompt of the pre-training mixture: no game information at all,
75
- # action-only Output Format. Kept for ablations; it does not match a <think>
76
- # prefill. Inference uses `build_system_prompt`.
77
- TRAINING_SYSTEM_PROMPT = '''You are an experienced PC game player, proficient in keyboard and mouse operations. Based on the current game screen and the corresponding historical trajectory, plan the next 200ms of actions, consisting of 6 steps. Each step is spaced 33ms apart. Every step lasts 33ms from its start time until the next step begins.
78
- **Output Format**
79
- <|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>
80
- **Explanation**
81
- 1. **Mouse Movement**: First, specify the relative displacement X, Y (X>0 means move right, Y>0
82
- means move down) and scroll amount Z (Z>0 means scroll up).
83
- 2. **Key Sequence**: Then list 6 groups of keys; within each group, keys are separated by spaces,
84
- and groups are separated by semicolons.
85
- - Each group can contain up to 4 keys.
86
- - If a group has no keys, leave it empty but keep the `;`.
87
- 3. Only output a plain string that conforms to the above format — no line breaks and no quotation marks.
88
- **Key Naming Rules**
89
- - Number keys `1-9`: use lowercase English words, e.g., `one` represents the `1` key on the keyboard.
90
- - Function keys `F1-F12`: use capitalized English words, e.g., `One` represents `F1`, `Two` represents `F2`, and so on.
91
- - Other keys (letters, Shift, Tab, Space, etc.): use the real keyboard name with an initial capital letter, e.g., `A`, `D`, `Shift`, `Space`, etc.'''
92
-
93
  USER_TURN_TEXT = "current game screen"
94
 
95
 
96
- def _output_format(reasoning: bool) -> str:
97
- return OUTPUT_FORMAT_THINK if reasoning else OUTPUT_FORMAT_PLAIN
98
-
99
-
100
  def build_system_prompt(
101
  game_name: str,
102
  *,
103
  rules: str | None = None,
104
  key_bindings: Iterable[tuple[str, str]] | dict[str, str] | None = None,
105
  mouse_aim: bool = True,
106
- reasoning: bool = True,
107
  ) -> str:
108
- """Standard key-list layout for any game (ViZDoom, OmniGameArena, or a new game).
109
 
110
  ``key_bindings`` maps training tokens to what they do, e.g.
111
  ``{"W": "Move forward", "LB": "Fire"}``; ``LMB``/``RMB``/``MMB`` are accepted
112
  as aliases of ``LB``/``RB``/``MB``. ``mouse_aim`` adds the camera line for
113
- games that are steered with the mouse. ``reasoning`` must match whether the
114
- client prefills ``<think>`` (see ``PolicyConfig.think_prefill``).
115
  """
116
  parts = [OPENING.format(name=game_name)]
117
  if rules and rules.strip():
@@ -122,36 +93,11 @@ def build_system_prompt(
122
  common.append(MOUSE_AIM_LINE)
123
  if common:
124
  parts.append(f"**Common Keys in {game_name}**\n" + "\n".join(common))
125
- parts.append("**Output Format**\n" + _output_format(reasoning))
126
  parts.append(TAIL)
127
  return "\n".join(parts)
128
 
129
 
130
- def build_gameworld_system_prompt(
131
- game_name: str | None,
132
- *,
133
- rules: str | None = None,
134
- controls: str | None = None,
135
- task: str | None = None,
136
- reasoning: bool = False,
137
- ) -> str:
138
- """Free-text layout (GameWorld): rules, role/controls and task as paragraphs.
139
-
140
- ``controls`` must already use training key names; see
141
- :func:`translate_controls`.
142
- """
143
- opening = OPENING if game_name else OPENING.replace(", and you are currently playing {name}", "")
144
- out = opening.format(name=game_name) + "\n"
145
- if rules:
146
- out += "\n**Game Rules**\n" + rules + "\n"
147
- if controls:
148
- out += "\n**Role and Controls**\n" + controls + "\n"
149
- if task:
150
- out += "\n**Task Instruction**\n" + task + "\n"
151
- out += "\n**Output Format**\n" + _output_format(reasoning) + "\n" + TAIL
152
- return out.strip()
153
-
154
-
155
  _PW_TO_TOKEN = PLAYWRIGHT_TO_TOKEN
156
  _PW_KEY_RE = re.compile(r"\b(" + "|".join(map(re.escape, _PW_TO_TOKEN)) + r")\b")
157
  _DIGIT_WORD = {"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
@@ -165,7 +111,7 @@ _LIST_DIGIT_RE = re.compile(r"(?m)^(\s*-\s*)(\d)(?=\s*[:/])")
165
 
166
 
167
  def translate_controls(text: str | None) -> str:
168
- """Rewrite a controls paragraph from browser key names into training tokens.
169
 
170
  ``ArrowLeft`` -> ``Left``, ``Escape`` -> ``Esc``, ...; single letters become
171
  upper case and digits become words, but only where they stand as keys
 
1
+ """System prompt for GameScaling checkpoints.
2
 
3
+ One skeleton, in this order:
4
 
5
  1. opening: gaming expert, the game's name, the 200 ms / 6 x 33 ms contract;
6
  2. ``**Game Rules**``: what the game is and how it is scored;
7
+ 3. ``**Common Keys in <game>**``: the keys this game uses, in the training vocabulary;
8
+ 4. ``**Output Format**``: empty-think action, or think + action;
9
+ 5. ``**Explanation**`` + ``**Key Naming Rules**``.
10
+
11
+ The ``**...**`` markers are literal plain-text section headers that the model
12
+ saw in training, not Markdown. Copy the strings, do not paraphrase them: the
13
+ checkpoints key their output tags off the exact Output Format section.
 
 
14
  """
15
 
16
  from __future__ import annotations
 
32
  "analysis is no longer valid, or a new objective appears."
33
  )
34
 
35
+ OUTPUT_FORMAT = (
 
 
 
36
  "**Action only (usual)**\n"
37
  "<think></think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10"
38
  "<|action_end|>\n"
 
66
  MOUSE_AIM_LINE = "- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"
67
  _MOUSE_BUTTON_ALIASES = {"LMB": "LB", "RMB": "RB", "MMB": "MB"}
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  USER_TURN_TEXT = "current game screen"
70
 
71
 
 
 
 
 
72
  def build_system_prompt(
73
  game_name: str,
74
  *,
75
  rules: str | None = None,
76
  key_bindings: Iterable[tuple[str, str]] | dict[str, str] | None = None,
77
  mouse_aim: bool = True,
 
78
  ) -> str:
79
+ """The standard system prompt for any game.
80
 
81
  ``key_bindings`` maps training tokens to what they do, e.g.
82
  ``{"W": "Move forward", "LB": "Fire"}``; ``LMB``/``RMB``/``MMB`` are accepted
83
  as aliases of ``LB``/``RB``/``MB``. ``mouse_aim`` adds the camera line for
84
+ games that are steered with the mouse. The same prompt serves both
85
+ ``PolicyConfig(reasoning=True)`` and ``reasoning=False``.
86
  """
87
  parts = [OPENING.format(name=game_name)]
88
  if rules and rules.strip():
 
93
  common.append(MOUSE_AIM_LINE)
94
  if common:
95
  parts.append(f"**Common Keys in {game_name}**\n" + "\n".join(common))
96
+ parts.append("**Output Format**\n" + OUTPUT_FORMAT)
97
  parts.append(TAIL)
98
  return "\n".join(parts)
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  _PW_TO_TOKEN = PLAYWRIGHT_TO_TOKEN
102
  _PW_KEY_RE = re.compile(r"\b(" + "|".join(map(re.escape, _PW_TO_TOKEN)) + r")\b")
103
  _DIGIT_WORD = {"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
 
111
 
112
 
113
  def translate_controls(text: str | None) -> str:
114
+ """Rewrite key names written for a browser / keyboard API into training tokens.
115
 
116
  ``ArrowLeft`` -> ``Left``, ``Escape`` -> ``Esc``, ...; single letters become
117
  upper case and digits become words, but only where they stand as keys
prompts/example_doom_battle1.txt CHANGED
@@ -1,12 +1,31 @@
1
  You are a gaming expert, and you are currently playing Doom Battle-1. You are proficient in keyboard and mouse operations. You understand game mechanics and combat pacing, can quickly extract key information from the current screen, and can think and make precise decisions at critical moments. Based on the current screen, plan the next 200ms of actions, consisting of 6 steps spaced 33ms apart. Each step lasts 33ms, until the next step begins. If the current situation continues the previous strategy, output the action directly. Think only when the situation changes significantly, the previous analysis is no longer valid, or a new objective appears.
2
  **Game Rules**
3
- Kill as many monsters as possible; collect health and ammo.
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  **Common Keys in Doom Battle-1**
5
  - W -> Move forward
6
- - S -> Move backward
7
  - A -> Strafe left
 
8
  - D -> Strafe right
9
- - LB -> Fire
 
 
 
 
 
 
10
  - Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
11
  **Output Format**
12
  **Action only (usual)**
 
1
  You are a gaming expert, and you are currently playing Doom Battle-1. You are proficient in keyboard and mouse operations. You understand game mechanics and combat pacing, can quickly extract key information from the current screen, and can think and make precise decisions at critical moments. Based on the current screen, plan the next 200ms of actions, consisting of 6 steps spaced 33ms apart. Each step lasts 33ms, until the next step begins. If the current situation continues the previous strategy, output the action directly. Think only when the situation changes significantly, the previous analysis is no longer valid, or a new objective appears.
2
  **Game Rules**
3
+ You are playing Doom, a first-person shooter, in an arena full of monsters. Survive and kill as many monsters as you can.
4
+
5
+ ## What you see on screen
6
+ - First-person view.
7
+ - The crosshair at screen centre marks where shots land.
8
+ - The status bar along the bottom shows HEALTH on the left and AMMO on the right.
9
+ - Monsters keep arriving for the whole episode; some throw fireballs from range.
10
+
11
+ ## Rules
12
+ - Score is the number of monsters you kill in the episode.
13
+ - Firing costs ammo, and the episode ends as soon as your health reaches 0, so dodging matters as much as shooting: standing still in the open is how most episodes end early.
14
+ - Health packs and ammo boxes lie around the map; walk over one to pick it up. Running out of either is fatal, so collect them while fighting.
15
+ - A target is hit when it is under the crosshair, so turn with the mouse until the monster is centred before firing.
16
+ - The map is one large open arena, so monsters can close in from any direction.
17
  **Common Keys in Doom Battle-1**
18
  - W -> Move forward
 
19
  - A -> Strafe left
20
+ - S -> Move backward
21
  - D -> Strafe right
22
+ - LB -> Fire weapon
23
+ - Ctrl -> Fire weapon (keyboard alternative)
24
+ - Shift -> Run
25
+ - Space -> Open doors / use switches
26
+ - one -> Select weapon slot 1
27
+ - two -> Select weapon slot 2
28
+ - three -> Select weapon slot 3
29
  - Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
30
  **Output Format**
31
  **Action only (usual)**
requirements.txt CHANGED
@@ -2,7 +2,5 @@
2
  requests>=2.31
3
  Pillow>=10.0
4
 
5
- # ViZDoom suite (integrations/vizdoom):
6
- # pip install vizdoom==1.2.4
7
  # Serving the checkpoint (separate environment is fine):
8
  # pip install "vllm>=0.17" # Qwen3.5 architecture; tested with 0.17.0
 
2
  requests>=2.31
3
  Pillow>=10.0
4
 
 
 
5
  # Serving the checkpoint (separate environment is fine):
6
  # pip install "vllm>=0.17" # Qwen3.5 architecture; tested with 0.17.0
tests/test_core.py CHANGED
@@ -10,7 +10,6 @@ from PIL import Image # noqa: E402
10
  from gamescaling import ( # noqa: E402
11
  NOOP_ACTION_TEXT,
12
  PolicyConfig,
13
- build_gameworld_system_prompt,
14
  build_request,
15
  build_system_prompt,
16
  finalize,
@@ -61,29 +60,30 @@ def test_lenient_recovers_skeleton():
61
 
62
 
63
  def test_finalize_restores_prefill_and_stop():
64
- cfg = PolicyConfig()
65
- out = finalize("\n\n</think>\n\n<|action_start|>0 0 0 ; W ; ; ; ; ; ", "stop", cfg)
66
  assert out.startswith("<think>") and out.endswith("<|action_end|>")
67
  assert parse_action(out).steps[0] == ["W"]
 
 
 
68
 
69
 
70
  def test_request_shape():
71
  cfg = PolicyConfig(model="m")
72
- assert cfg.image_size is None
73
  body = build_request([{"role": "system", "content": "s"}], cfg)
74
  assert body["messages"][-1] == {"role": "assistant", "content": "<think>"}
75
  assert body["continue_final_message"] and not body["add_generation_prompt"]
76
  assert body["stop"] == ["<|action_end|>"] and body["skip_special_tokens"] is False
77
- oga = PolicyConfig.preset("omnigamearena")
78
- assert oga.image_size == (854, 480) and oga.think_prefill
79
- gw = build_request([], PolicyConfig.preset("gameworld"))
80
- assert "continue_final_message" not in gw and gw["stop_token_ids"] == [151670]
81
 
82
 
83
  def test_image_encoding():
84
  img = Image.new("RGB", (1280, 720))
85
  assert encode_image(img, PolicyConfig()).startswith("data:image/jpeg;base64,")
86
- assert encode_image(img, PolicyConfig.preset("gameworld")).startswith("data:image/png;base64,")
87
 
88
 
89
  def test_prompt_sections_in_order():
@@ -92,10 +92,6 @@ def test_prompt_sections_in_order():
92
  "**Action only (usual)**", "**Explanation**", "**Key Naming Rules**"]
93
  idx = [p.index(s) for s in order]
94
  assert idx == sorted(idx)
95
- gw = build_gameworld_system_prompt("2048", rules="Merge tiles.", controls="Use the arrow keys.",
96
- task="Reach 256.")
97
- assert "\n\n**Game Rules**\nMerge tiles.\n\n**Role and Controls**\n" in gw
98
- assert "**Action only" not in gw
99
 
100
 
101
  def test_prompt_template_file_matches_builder():
@@ -109,6 +105,16 @@ def test_prompt_template_file_matches_builder():
109
  assert filled == build_system_prompt("Doom", rules="Kill monsters.", key_bindings=keys)
110
 
111
 
 
 
 
 
 
 
 
 
 
 
112
  def test_translate_controls():
113
  text = "- ArrowLeft: move left\n- a/d: strafe\n- 1-9: pick item\nPress Escape to pause."
114
  out = translate_controls(text)
 
10
  from gamescaling import ( # noqa: E402
11
  NOOP_ACTION_TEXT,
12
  PolicyConfig,
 
13
  build_request,
14
  build_system_prompt,
15
  finalize,
 
60
 
61
 
62
  def test_finalize_restores_prefill_and_stop():
63
+ out = finalize("\n\n</think>\n\n<|action_start|>0 0 0 ; W ; ; ; ; ; ", "stop", PolicyConfig())
 
64
  assert out.startswith("<think>") and out.endswith("<|action_end|>")
65
  assert parse_action(out).steps[0] == ["W"]
66
+ out = finalize("\n\n<|action_start|>0 0 0 ; ; D ; ; ; ; ", "stop", PolicyConfig(reasoning=False))
67
+ assert out.startswith("<think>\n\n</think>\n\n<|action_start|>")
68
+ assert parse_action(out).steps[1] == ["D"]
69
 
70
 
71
  def test_request_shape():
72
  cfg = PolicyConfig(model="m")
73
+ assert cfg.image_size is None and cfg.reasoning
74
  body = build_request([{"role": "system", "content": "s"}], cfg)
75
  assert body["messages"][-1] == {"role": "assistant", "content": "<think>"}
76
  assert body["continue_final_message"] and not body["add_generation_prompt"]
77
  assert body["stop"] == ["<|action_end|>"] and body["skip_special_tokens"] is False
78
+ body = build_request([{"role": "system", "content": "s"}], PolicyConfig(reasoning=False))
79
+ assert body["messages"][-1] == {"role": "assistant", "content": "<think>\n\n</think>"}
80
+ assert body["continue_final_message"]
 
81
 
82
 
83
  def test_image_encoding():
84
  img = Image.new("RGB", (1280, 720))
85
  assert encode_image(img, PolicyConfig()).startswith("data:image/jpeg;base64,")
86
+ assert encode_image(img, PolicyConfig(image_format="png")).startswith("data:image/png;base64,")
87
 
88
 
89
  def test_prompt_sections_in_order():
 
92
  "**Action only (usual)**", "**Explanation**", "**Key Naming Rules**"]
93
  idx = [p.index(s) for s in order]
94
  assert idx == sorted(idx)
 
 
 
 
95
 
96
 
97
  def test_prompt_template_file_matches_builder():
 
105
  assert filled == build_system_prompt("Doom", rules="Kill monsters.", key_bindings=keys)
106
 
107
 
108
+ def test_doom_example_matches_builder():
109
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
110
+ with open(os.path.join(root, "prompts", "example_doom_battle1.txt")) as f:
111
+ example = f.read().rstrip("\n")
112
+ rules = example.split("**Game Rules**\n", 1)[1].split("\n**Common Keys in", 1)[0]
113
+ keys = example.split("**Common Keys in Doom Battle-1**\n", 1)[1].split("\n**Output Format**", 1)[0]
114
+ bindings = [line[2:].split(" -> ", 1) for line in keys.splitlines() if not line.startswith("- Mouse X Y")]
115
+ assert example == build_system_prompt("Doom Battle-1", rules=rules, key_bindings=bindings)
116
+
117
+
118
  def test_translate_controls():
119
  text = "- ArrowLeft: move left\n- a/d: strafe\n- 1-9: pick item\nPress Escape to pause."
120
  out = translate_controls(text)