AnshulRaj commited on
Commit
c2a6752
·
verified ·
1 Parent(s): b0ccbac

Sync agent.py from GitHub project

Browse files
Files changed (1) hide show
  1. agent.py +285 -0
agent.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ollama-backed game generation agent using structured JSON output."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import json
6
+ import re
7
+
8
+ import ollama
9
+ from pydantic import ValidationError
10
+
11
+ from schema import (
12
+ CanvasGame, CollisionAction, InputAction, InputKey, MovementPattern, Shape,
13
+ )
14
+
15
+ # A canonical example game baked into the prompt — the single strongest signal
16
+ # for the model to follow the schema structure correctly.
17
+ _EXAMPLE = json.dumps({
18
+ "title": "Neon Dodge",
19
+ "width": 600,
20
+ "height": 500,
21
+ "background_color": "#0a0a0a",
22
+ "sprites": [
23
+ {
24
+ "id": "player", "shape": "triangle", "color": "#00ffcc",
25
+ "x": 300, "y": 440, "width": 32, "height": 32,
26
+ "movement_pattern": "keyboard_horizontal",
27
+ "speed": 5.0, "is_player": True, "count": 1,
28
+ },
29
+ {
30
+ "id": "rock", "shape": "circle", "color": "#ff4444",
31
+ "x": 100, "y": 0, "width": 26, "height": 26,
32
+ "movement_pattern": "rain_down",
33
+ "speed": 4.0, "is_player": False, "count": 8,
34
+ },
35
+ ],
36
+ "collision_rules": [
37
+ {"between": ["player", "rock"], "action": "game_over", "value": 1},
38
+ ],
39
+ "input_bindings": [
40
+ {"key": "arrow_left", "action": "move_left", "target": "player"},
41
+ {"key": "arrow_right", "action": "move_right", "target": "player"},
42
+ ],
43
+ "score_display": {
44
+ "position": "top_right", "font_size": 22,
45
+ "color": "#00ffcc", "label": "SCORE",
46
+ },
47
+ "lives": 3,
48
+ }, indent=2)
49
+
50
+ _SYSTEM_PROMPT = f"""/no_think
51
+ You are a creative arcade game designer. Output a single JSON object.
52
+ The JSON must start with {{ and end with }} — no wrapper key, no markdown fences, no extra text.
53
+
54
+ REQUIRED top-level keys (use exactly these names):
55
+ "title", "width", "height", "background_color",
56
+ "sprites" ← NON-EMPTY list; must contain a player and at least one enemy or item
57
+ "collision_rules" ← NON-EMPTY list
58
+ "input_bindings" ← NON-EMPTY list whose target matches the player sprite id
59
+ "score_display", "lives"
60
+
61
+ MOVEMENT PATTERNS:
62
+ keyboard_horizontal — player moves left/right with arrow keys ← use for most players
63
+ keyboard_all — player moves in all 4 directions
64
+ rain_down — falls from top, respawns (enemies/coins)
65
+ bounce — bounces off all walls
66
+ chase_player — homes toward the player
67
+ zigzag — side-to-side drift downward
68
+ circular — orbits a fixed point
69
+ static — doesn't move
70
+
71
+ VALID VALUES (use only these — no others):
72
+ shape: circle | rectangle | triangle | star
73
+ movement_pattern: keyboard_horizontal | keyboard_all | rain_down | bounce |
74
+ chase_player | zigzag | circular | static
75
+ collision action: game_over | score_plus | score_minus | destroy_target | bounce_back
76
+ input key: arrow_left | arrow_right | arrow_up | arrow_down | space
77
+ input action: move_left | move_right | move_up | move_down (no "shoot")
78
+ score position: top_left | top_right | top_center
79
+
80
+ RULES:
81
+ - Exactly ONE sprite with is_player=true; use keyboard_horizontal or keyboard_all
82
+ - input_bindings MUST reference the player sprite id exactly
83
+ - Use count=6-10 for enemies/collectibles so the screen feels alive
84
+ - Colors: vivid neon hex — #0ff, #f0f, #ff0, #0f0, #f44, #f80, #88f, #0f8, etc.
85
+ - game_over action when player touches an enemy
86
+ - score_plus when player touches a collectible; destroy_target to remove an enemy on touch
87
+ - Title: 2-4 evocative words, e.g. "Void Escape", "Star Blitz", "Neon Rain"
88
+
89
+ EXAMPLE (follow this structure exactly):
90
+ {_EXAMPLE}
91
+ """
92
+
93
+
94
+ _VALID_SHAPES = {s.value for s in Shape}
95
+ _VALID_MOVEMENTS = {m.value for m in MovementPattern}
96
+ _VALID_INPUT_KEYS = {k.value for k in InputKey}
97
+ _VALID_INPUT_ACTS = {a.value for a in InputAction}
98
+ _VALID_COL_ACTS = {a.value for a in CollisionAction}
99
+
100
+
101
+ _FIELD_ALIASES: dict[str, str] = {
102
+ "game_title": "title",
103
+ "name": "title",
104
+ "background": "background_color",
105
+ "bg_color": "background_color",
106
+ "backgroundColor": "background_color",
107
+ "sprite_list": "sprites",
108
+ "sprite_objects": "sprites",
109
+ "controls": "input_bindings",
110
+ "inputs": "input_bindings",
111
+ "key_bindings": "input_bindings",
112
+ "collisions": "collision_rules",
113
+ "collision_list": "collision_rules",
114
+ "hud": "score_display",
115
+ "display": "score_display",
116
+ "max_lives": "lives",
117
+ "num_lives": "lives",
118
+ }
119
+
120
+
121
+ def _coerce(data: dict) -> dict:
122
+ """Normalise field names, clamp values, and fix invalid enums before Pydantic validation."""
123
+ data = copy.deepcopy(data)
124
+
125
+ # Rename aliased top-level keys
126
+ for alias, canonical in _FIELD_ALIASES.items():
127
+ if alias in data and canonical not in data:
128
+ data[canonical] = data.pop(alias)
129
+
130
+ for s in data.get("sprites", []):
131
+ if s.get("shape") not in _VALID_SHAPES:
132
+ s["shape"] = "circle"
133
+ if s.get("movement_pattern") not in _VALID_MOVEMENTS:
134
+ s["movement_pattern"] = "static"
135
+ s["speed"] = max(0.5, min(15.0, float(s.get("speed", 3.0))))
136
+ s["count"] = max(1, min(20, int( s.get("count", 1 ))))
137
+ s["width"] = max(5, min(150, float(s.get("width", 30 ))))
138
+ s["height"] = max(5, min(150, float(s.get("height", 30 ))))
139
+
140
+ data["input_bindings"] = [
141
+ b for b in data.get("input_bindings", [])
142
+ if b.get("key") in _VALID_INPUT_KEYS
143
+ and b.get("action") in _VALID_INPUT_ACTS
144
+ ]
145
+
146
+ clean_rules = []
147
+ for r in data.get("collision_rules", []):
148
+ if r.get("action") not in _VALID_COL_ACTS:
149
+ r["action"] = "game_over"
150
+ between = r.get("between", [])
151
+ if isinstance(between, list) and len(between) >= 2:
152
+ r["between"] = between[:2]
153
+ clean_rules.append(r)
154
+ data["collision_rules"] = clean_rules
155
+
156
+ data["width"] = max(300, min(800, int(data.get("width", 600))))
157
+ data["height"] = max(300, min(650, int(data.get("height", 500))))
158
+
159
+ return data
160
+
161
+
162
+ class GameAgent:
163
+ def __init__(
164
+ self,
165
+ model: str = "qwen3.5:9b",
166
+ ollama_host: str = "http://127.0.0.1:11434",
167
+ ) -> None:
168
+ self.model = model
169
+ self.client = ollama.Client(host=ollama_host)
170
+ self.current_game_json: dict | None = None
171
+
172
+ def _generate(self, messages: list[dict], max_retries: int = 2) -> CanvasGame:
173
+ last_error: Exception | None = None
174
+
175
+ for attempt in range(max_retries + 1):
176
+ response = self.client.chat(
177
+ model=self.model,
178
+ messages=messages,
179
+ format="json",
180
+ options={"temperature": 0.7 + attempt * 0.05, "num_predict": 3000},
181
+ )
182
+ content: str = response.message.content
183
+
184
+ # Qwen3 may emit <think>…</think> before the JSON
185
+ if "</think>" in content:
186
+ content = content.split("</think>")[-1].strip()
187
+
188
+ try:
189
+ data = json.loads(content)
190
+ except json.JSONDecodeError:
191
+ # Extract first {...} block from surrounding prose
192
+ match = re.search(r"\{.*\}", content, re.DOTALL)
193
+ if not match:
194
+ last_error = ValueError(f"No JSON found in response: {content[:200]}")
195
+ continue
196
+ try:
197
+ data = json.loads(match.group())
198
+ except json.JSONDecodeError as e:
199
+ last_error = e
200
+ continue
201
+
202
+ # Unwrap common model wrapping patterns like {"game": {...}}
203
+ for _key in ("game", "config", "output", "result", "data", "arcade_game"):
204
+ if isinstance(data, dict) and _key in data and isinstance(data[_key], dict):
205
+ data = data[_key]
206
+ break
207
+
208
+ data = _coerce(data)
209
+
210
+ try:
211
+ game = CanvasGame.model_validate(data)
212
+ except ValidationError as e:
213
+ last_error = e
214
+ continue
215
+
216
+ # Quality gate: must have at least one player sprite
217
+ if not any(s.is_player for s in game.sprites):
218
+ last_error = ValueError(
219
+ "Model did not include a player sprite (is_player=true). "
220
+ "Retrying with stronger hint."
221
+ )
222
+ # Inject a stronger hint for the next attempt
223
+ messages = _inject_player_hint(messages)
224
+ continue
225
+
226
+ self.current_game_json = game.model_dump()
227
+ return game
228
+
229
+ raise ValueError(
230
+ f"Failed to generate a valid game after {max_retries + 1} attempts. "
231
+ f"Last error: {last_error}"
232
+ )
233
+
234
+ def create_game(self, prompt: str) -> CanvasGame:
235
+ messages = [
236
+ {"role": "system", "content": _SYSTEM_PROMPT},
237
+ {
238
+ "role": "user",
239
+ "content": (
240
+ f"Create a game: {prompt}\n\n"
241
+ "Output a single JSON object with these exact top-level keys: "
242
+ "title, width, height, background_color, sprites, collision_rules, "
243
+ "input_bindings, score_display, lives."
244
+ ),
245
+ },
246
+ ]
247
+ return self._generate(messages)
248
+
249
+ def modify_game(self, modification: str) -> CanvasGame:
250
+ if self.current_game_json is None:
251
+ raise ValueError("No active game to modify. Create a game first.")
252
+ current = json.dumps(self.current_game_json, indent=2)
253
+ messages = [
254
+ {"role": "system", "content": _SYSTEM_PROMPT},
255
+ {
256
+ "role": "user",
257
+ "content": (
258
+ f"Current game JSON:\n{current}\n\n"
259
+ f"Modify it: {modification}\n\n"
260
+ "Output the complete updated JSON object."
261
+ ),
262
+ },
263
+ ]
264
+ return self._generate(messages)
265
+
266
+ def reset(self) -> None:
267
+ self.current_game_json = None
268
+
269
+
270
+ def _inject_player_hint(messages: list[dict]) -> list[dict]:
271
+ """Append a reminder that a player sprite is required."""
272
+ messages = list(messages)
273
+ messages.append({
274
+ "role": "assistant",
275
+ "content": "{}", # placeholder so next user turn makes sense
276
+ })
277
+ messages.append({
278
+ "role": "user",
279
+ "content": (
280
+ "IMPORTANT: your sprites array must include exactly one sprite "
281
+ "with \"is_player\": true and movement_pattern \"keyboard_horizontal\" "
282
+ "or \"keyboard_all\". Please regenerate the complete JSON."
283
+ ),
284
+ })
285
+ return messages