netris 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netris/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Netris: a Gymnasium environment for GameBoy Tetris."""
2
+
3
+ from .envs import NetrisEnvConfig, TetrisEnv
4
+ from .registration import ENV_ID, register_env
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["ENV_ID", "NetrisEnvConfig", "TetrisEnv", "__version__", "register_env"]
9
+
10
+ register_env()
@@ -0,0 +1,4 @@
1
+ from .config import NetrisEnvConfig
2
+ from .tetris_env import TetrisEnv
3
+
4
+ __all__ = ["NetrisEnvConfig", "TetrisEnv"]
netris/envs/config.py ADDED
@@ -0,0 +1,36 @@
1
+ """Environment configuration."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class NetrisEnvConfig:
8
+ """Knobs that do not change the observation/action spaces."""
9
+
10
+ include_next_piece: bool = False
11
+ watchdog_steps: int = 1000
12
+ collect_metrics: bool = True
13
+
14
+ line_clear_rewards: dict = field(
15
+ default_factory=lambda: {1: 5.0, 2: 15.0, 3: 40.0}
16
+ )
17
+ tetris_reward: float = 100.0
18
+ hole_penalty: float = 0.5
19
+ aggregate_height_penalty: float = 0.005
20
+ bumpiness_penalty: float = 0.01
21
+ game_over_penalty: float = -10.0
22
+ survival_bonus: float = 1.0
23
+
24
+ def to_dict(self):
25
+ return {
26
+ "include_next_piece": self.include_next_piece,
27
+ "watchdog_steps": self.watchdog_steps,
28
+ "collect_metrics": self.collect_metrics,
29
+ "line_clear_rewards": dict(self.line_clear_rewards),
30
+ "tetris_reward": self.tetris_reward,
31
+ "hole_penalty": self.hole_penalty,
32
+ "aggregate_height_penalty": self.aggregate_height_penalty,
33
+ "bumpiness_penalty": self.bumpiness_penalty,
34
+ "game_over_penalty": self.game_over_penalty,
35
+ "survival_bonus": self.survival_bonus,
36
+ }
netris/envs/metrics.py ADDED
@@ -0,0 +1,136 @@
1
+ import json
2
+ from collections import Counter
3
+ from datetime import UTC, datetime
4
+
5
+ import numpy as np
6
+
7
+
8
+ class MetricsCollector:
9
+ """In-memory per-episode metrics.
10
+
11
+ Persisting metrics to disk is the responsibility of the main-process
12
+ callbacks: environments may run in subprocesses (SubprocVecEnv) and must
13
+ not race on shared files.
14
+ """
15
+
16
+ def __init__(self):
17
+ self._reset_episode()
18
+
19
+ def _reset_episode(self):
20
+ self.episode_actions = Counter()
21
+ self.episode_rotations = 0
22
+ self.episode_placement_columns = []
23
+ self.episode_placement_heights = []
24
+ self.episode_reward_breakdowns = []
25
+ self.episode_line_clears = []
26
+ self.episode_max_height = 0
27
+ self.episode_bumpiness_values = []
28
+ self.episode_holes_values = []
29
+
30
+ def register_action(self, action):
31
+ self.episode_actions[int(action)] += 1
32
+
33
+ def register_rotation(self):
34
+ self.episode_rotations += 1
35
+
36
+ def register_piece_placed(self, column, height):
37
+ self.episode_placement_columns.append(column)
38
+ self.episode_placement_heights.append(height)
39
+
40
+ def register_reward_breakdown(self, *, reward_lines=0.0, reward_holes=0.0,
41
+ reward_height=0.0, reward_bumpiness=0.0,
42
+ reward_clean=0.0, reward_total=0.0):
43
+ self.episode_reward_breakdowns.append({
44
+ "lines": reward_lines,
45
+ "holes": reward_holes,
46
+ "height": reward_height,
47
+ "bumpiness": reward_bumpiness,
48
+ "clean": reward_clean,
49
+ "total": reward_total,
50
+ })
51
+
52
+ def register_line_clear(self, lines_delta):
53
+ self.episode_line_clears.append(lines_delta)
54
+
55
+ def update_max_height(self, height):
56
+ self.episode_max_height = max(self.episode_max_height, height)
57
+
58
+ def record_bumpiness(self, bumpiness):
59
+ self.episode_bumpiness_values.append(bumpiness)
60
+
61
+ def record_holes(self, holes):
62
+ self.episode_holes_values.append(holes)
63
+
64
+ def episode_summary(self, episode, steps, pieces, total_lines, total_reward):
65
+ total_lines_from_breakdown = sum(
66
+ d.get("lines", 0) for d in self.episode_reward_breakdowns
67
+ )
68
+ total_holes = sum(
69
+ d.get("holes", 0) for d in self.episode_reward_breakdowns
70
+ )
71
+ total_height = sum(
72
+ d.get("height", 0) for d in self.episode_reward_breakdowns
73
+ )
74
+ total_bumpiness = sum(
75
+ d.get("bumpiness", 0) for d in self.episode_reward_breakdowns
76
+ )
77
+
78
+ avg_placement_col = (
79
+ np.mean(self.episode_placement_columns)
80
+ if self.episode_placement_columns else 0.0
81
+ )
82
+ avg_placement_height = (
83
+ np.mean(self.episode_placement_heights)
84
+ if self.episode_placement_heights else 0.0
85
+ )
86
+ avg_bumpiness = (
87
+ np.mean(self.episode_bumpiness_values)
88
+ if self.episode_bumpiness_values else 0.0
89
+ )
90
+ avg_holes = (
91
+ np.mean(self.episode_holes_values)
92
+ if self.episode_holes_values else 0.0
93
+ )
94
+
95
+ return {
96
+ "timestamp": datetime.now(UTC).isoformat(),
97
+ "episode": episode,
98
+ "steps": steps,
99
+ "pieces": pieces,
100
+ "lines_cleared": total_lines,
101
+ "max_height": self.episode_max_height,
102
+ "avg_bumpiness": round(float(avg_bumpiness), 3),
103
+ "avg_holes": round(float(avg_holes), 3),
104
+ "reward_total": round(float(total_reward), 4),
105
+ "reward_lines": round(total_lines_from_breakdown, 4),
106
+ "reward_holes": round(total_holes, 4),
107
+ "reward_height": round(total_height, 4),
108
+ "reward_bumpiness": round(total_bumpiness, 4),
109
+ "rotations": self.episode_rotations,
110
+ "avg_placement_col": round(float(avg_placement_col), 3),
111
+ "avg_placement_height": round(float(avg_placement_height), 3),
112
+ "action_distribution": json.dumps(dict(self.episode_actions)),
113
+ "line_clear_distribution": json.dumps(dict(Counter(self.episode_line_clears))),
114
+ }
115
+
116
+ def reset_episode(self):
117
+ self._reset_episode()
118
+
119
+ def get_placement_heatmap(self):
120
+ heatmap = np.zeros((18, 10), dtype=np.int32)
121
+ for col, height in zip(self.episode_placement_columns, self.episode_placement_heights):
122
+ row = 18 - 1 - height
123
+ row = max(0, min(17, row))
124
+ col = max(0, min(9, col))
125
+ heatmap[row, col] += 1
126
+ return heatmap
127
+
128
+
129
+ class NullMetricsCollector:
130
+ """No-op collector used when metrics collection is disabled."""
131
+
132
+ def __getattr__(self, name):
133
+ return lambda *args, **kwargs: None
134
+
135
+ def episode_summary(self, **kwargs):
136
+ return {}
@@ -0,0 +1,609 @@
1
+ import logging
2
+ import os
3
+ from typing import ClassVar
4
+
5
+ import numpy as np
6
+ from gymnasium import Env, spaces
7
+ from pyboy import PyBoy
8
+
9
+ from ..gameboy.pieces import (
10
+ PIECE_TILES,
11
+ TETROMINO_NUMBERS,
12
+ TETROMINO_TYPES,
13
+ TILE_TO_PIECE,
14
+ )
15
+ from .config import NetrisEnvConfig
16
+ from .metrics import MetricsCollector, NullMetricsCollector
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ ROTATE_TICKS = 2
21
+ MOVE_TICKS = 2
22
+ DROP_TICKS = 2
23
+
24
+ MAX_MOVE_ATTEMPTS = 20
25
+ MAX_DROP_ATTEMPTS = 40
26
+
27
+ # After a piece locks the game may blink completed rows on/off for ~40 frames
28
+ # before removing them, so the board must be considered settled only after a
29
+ # window longer than the blink gap (SETTLE_CHUNK * SETTLE_STABLE_CHUNKS ticks).
30
+ SETTLE_CHUNK = 4
31
+ SETTLE_STABLE_CHUNKS = 3
32
+ SETTLE_MAX_FRAMES = 240
33
+
34
+ # The game's own line counter (screen digits) updates slightly after the clear
35
+ # animation; poll it a bit longer when completed rows were seen.
36
+ LINES_CONFIRM_CHUNK = 4
37
+ LINES_CONFIRM_MAX_FRAMES = 64
38
+
39
+
40
+ class TetrisEnv(Env):
41
+ """GameBoy Tetris as a placement-based Gymnasium environment.
42
+
43
+ Action: ``rotation (0-3) * 10 + column (0-9)``; one step places one piece.
44
+ The ROM is never bundled: pass ``rom_path`` or set ``NETRIS_ROM``.
45
+ """
46
+
47
+ metadata: ClassVar[dict] = {"render_modes": ["rgb_array", "human"], "render_fps": 60}
48
+
49
+ def __init__(self, rom_path=None, render_mode=None, log_level="error",
50
+ config=None, window=None) -> None:
51
+ self.config = config if config is not None else NetrisEnvConfig()
52
+
53
+ self.rom_path = rom_path or os.environ.get("NETRIS_ROM", "")
54
+ if not self.rom_path:
55
+ raise ValueError(
56
+ "No ROM path provided. Pass rom_path or set the NETRIS_ROM "
57
+ "environment variable. The Tetris ROM is not distributed with "
58
+ "this package."
59
+ )
60
+ if not os.path.exists(self.rom_path):
61
+ raise FileNotFoundError(f"ROM file not found: {self.rom_path}")
62
+
63
+ if render_mode is not None and render_mode not in self.metadata["render_modes"]:
64
+ raise ValueError(
65
+ f"Unsupported render_mode {render_mode!r}; "
66
+ f"choose one of {self.metadata['render_modes']} or None."
67
+ )
68
+ self.render_mode = render_mode
69
+ self.window = window or ("SDL2" if render_mode == "human" else "null")
70
+ self.include_next_piece = self.config.include_next_piece
71
+
72
+ logging.basicConfig(level=log_level.upper())
73
+
74
+ self.board_shape = (18, 10)
75
+
76
+ # Action: placement = rotation (0-3) * 10 + column (0-9)
77
+ self.action_space = spaces.Discrete(40)
78
+
79
+ observation_spaces = {
80
+ "board": spaces.Box(
81
+ low=0, high=1, shape=(1, 18, 10), dtype=np.float32
82
+ ),
83
+ "current_piece": spaces.Box(
84
+ low=0, high=1, shape=(7,), dtype=np.float32
85
+ ),
86
+ "row_completion": spaces.Box(
87
+ low=0, high=10, shape=(18,), dtype=np.float32
88
+ ),
89
+ "holes": spaces.Box(low=0, high=180, shape=(1,), dtype=np.float32),
90
+ "bumpiness": spaces.Box(low=0, high=180, shape=(1,), dtype=np.float32),
91
+ "aggregate_height": spaces.Box(low=0, high=180, shape=(1,), dtype=np.float32),
92
+ }
93
+ if self.include_next_piece:
94
+ observation_spaces["next_piece"] = spaces.Box(
95
+ low=0, high=1, shape=(7,), dtype=np.float32
96
+ )
97
+ self.observation_space = spaces.Dict(observation_spaces)
98
+
99
+ self.current_score = 0
100
+ self.lines_cleared = 0
101
+ self.n_deaths = 0
102
+ self.episode_steps = 0
103
+ self.pieces_placed = 0
104
+ self.steps_since_last_line_clear = 0
105
+ self.piece_rotation = 0
106
+
107
+ self.previous_locked_board = None
108
+ self.previous_holes = 0
109
+ self.previous_height = 0
110
+ self.previous_aggregate_height = 0
111
+ self.previous_bumpiness = 0
112
+
113
+ # Optional capture of frames when lines are cleared. Disabled by default
114
+ # so training workers do not pay the copy cost; enabled for eval only.
115
+ self.capture_line_clear = False
116
+ self.line_clear_frames = []
117
+
118
+ # Optional per-tick frame recording for visualization. Disabled by
119
+ # default; when enabled, gameplay ticks render and frames are stored in
120
+ # `recorded_frames` (drained by the caller).
121
+ self.record_frames = False
122
+ self.record_every = 1
123
+ self.max_recorded_frames = 10000
124
+ self.recorded_frames = []
125
+ self._record_counter = 0
126
+
127
+ self.collector = (
128
+ MetricsCollector() if self.config.collect_metrics
129
+ else NullMetricsCollector()
130
+ )
131
+
132
+ # Address of the `ld a,(0xC213)` instruction that loads the next
133
+ # tetromino when spawning. It differs between ROM revisions, so it is
134
+ # looked up in the ROM instead of hardcoded.
135
+ self._next_piece_load_addr = self._find_next_piece_load_addr()
136
+
137
+ self.emulator = PyBoy(
138
+ self.rom_path,
139
+ window=self.window,
140
+ )
141
+ assert self.emulator.cartridge_title == "TETRIS"
142
+ self.emulator.set_emulation_speed(0)
143
+ self.tetris = self.emulator.game_wrapper
144
+ self.tetris.start_game()
145
+
146
+ self.reset()
147
+
148
+ def _find_next_piece_load_addr(self):
149
+ with open(self.rom_path, "rb") as rom_file:
150
+ rom = rom_file.read()
151
+ return rom.find(bytes([0xFA, 0x13, 0xC2]))
152
+
153
+ def force_next_tetromino(self, shape):
154
+ """Test helper: force every subsequently spawned piece to `shape`."""
155
+ addr = self._next_piece_load_addr
156
+ if addr < 0:
157
+ raise RuntimeError("Could not locate next-tetromino load in ROM")
158
+ self.emulator.memory[0, addr] = 0x3E # LD A, n
159
+ self.emulator.memory[0, addr + 1] = TETROMINO_NUMBERS[shape]
160
+ self.emulator.memory[0, addr + 2] = 0x00 # NOP
161
+
162
+ def reset(self, seed=None, options=None):
163
+ super().reset(seed=seed)
164
+ timer_div = int(seed) & 0xFF if seed is not None else None
165
+ self.tetris.reset_game(timer_div=timer_div)
166
+
167
+ for _ in range(50):
168
+ self.emulator.tick(render=False)
169
+
170
+ locked_board = self.get_locked_board()
171
+ self.previous_locked_board = locked_board.copy()
172
+ self.previous_holes = self.count_holes(locked_board)
173
+ self.previous_height = self.get_max_height(locked_board)
174
+ self.previous_aggregate_height = self.get_aggregate_height(locked_board)
175
+ self.previous_bumpiness = self.get_bumpiness(locked_board)
176
+
177
+ self.current_score = 0
178
+ self.lines_cleared = 0
179
+ self.episode_steps = 0
180
+ self.pieces_placed = 0
181
+ self.steps_since_last_line_clear = 0
182
+ self.piece_rotation = 0
183
+ self.line_clear_frames = []
184
+
185
+ self.collector.reset_episode()
186
+
187
+ observation = self.get_observation()
188
+
189
+ logger.info(
190
+ f"[EPISODE_START] height={self.previous_height}, holes={self.previous_holes}, "
191
+ f"bumpiness={self.previous_bumpiness}"
192
+ )
193
+
194
+ return observation, {}
195
+
196
+ def _read_active_cells(self):
197
+ """Read the falling piece cells from OAM: list of (col, row, tile_id)."""
198
+ oam = self.emulator.memory[0xFE00:0xFEA0]
199
+ cells = []
200
+ for i in range(0, 160, 4):
201
+ tile = int(oam[i + 2])
202
+ if tile not in PIECE_TILES:
203
+ continue
204
+ x = int(oam[i + 1]) - 8
205
+ y = int(oam[i]) - 16
206
+ col = x // 8 - 2
207
+ row = y // 8
208
+ if 0 <= col < self.board_shape[1] and 0 <= row < self.board_shape[0]:
209
+ cells.append((col, row, tile))
210
+ return cells
211
+
212
+ def get_current_piece_name(self):
213
+ cells = self._read_active_cells()
214
+ if not cells:
215
+ return None
216
+ return TILE_TO_PIECE[cells[0][2]]
217
+
218
+ def get_current_piece_onehot(self):
219
+ onehot = np.zeros(7, dtype=np.float32)
220
+ name = self.get_current_piece_name()
221
+ if name is not None:
222
+ onehot[TETROMINO_TYPES.index(name)] = 1.0
223
+ return onehot
224
+
225
+ def get_next_piece_name(self):
226
+ """Next piece as shown in the on-screen NEXT panel.
227
+
228
+ Read from the preview sprites (screen state), never from ROM/RNG, so it
229
+ only uses information a human player can see.
230
+ """
231
+ oam = self.emulator.memory[0xFE00:0xFEA0]
232
+ for i in range(0, 160, 4):
233
+ tile = int(oam[i + 2])
234
+ if tile not in PIECE_TILES:
235
+ continue
236
+ x = int(oam[i + 1]) - 8
237
+ col = x // 8 - 2
238
+ if col >= self.board_shape[1]:
239
+ return TILE_TO_PIECE[tile]
240
+ # Fallback: the same value the game uses to draw the preview
241
+ try:
242
+ return self.tetris.next_tetromino()
243
+ except Exception: # noqa: BLE001
244
+ return None
245
+
246
+ def get_next_piece_onehot(self):
247
+ onehot = np.zeros(7, dtype=np.float32)
248
+ name = self.get_next_piece_name()
249
+ if name is not None:
250
+ onehot[TETROMINO_TYPES.index(name)] = 1.0
251
+ return onehot
252
+
253
+ def get_locked_board(self):
254
+ background = np.asarray(self.emulator.tilemap_background[2:12, 0:18])
255
+ # Only actual tetromino tiles count as filled. From pyboy's
256
+ # game_wrapper_tetris.py: BLANK=47, BLACK(losing)=135, and pieces are
257
+ # 129,130,131,132,133,134 (J,Z,O,L,T,S) and 128,136,137,138,139,143 (I).
258
+ # Everything else (UI text, preview, numbers) is treated as empty.
259
+ piece_tiles = {128, 129, 130, 131, 132, 133, 134, 136, 137, 138, 139, 143}
260
+ board = np.isin(background, list(piece_tiles)).astype(np.uint8)
261
+ return board
262
+
263
+ def get_board(self):
264
+ """Board with the active piece rendered on top (18x10, uint8)."""
265
+ board = self.get_locked_board().copy()
266
+ for col, row, _tile in self._read_active_cells():
267
+ board[row, col] = 1
268
+
269
+ self.observation = board
270
+ return self.observation
271
+
272
+ def render(self):
273
+ """Gymnasium render: "rgb_array" returns the GameBoy screen, "human"
274
+ updates the PyBoy window (requires a display)."""
275
+ if self.render_mode is None:
276
+ return None
277
+ self.emulator.tick(1, render=True)
278
+ if self.render_mode == "rgb_array":
279
+ return np.asarray(
280
+ self.emulator.screen.image.convert("RGB"), dtype=np.uint8
281
+ )
282
+ return None
283
+
284
+ def close(self):
285
+ try:
286
+ self.emulator.stop(save=False)
287
+ except Exception as error: # noqa: BLE001
288
+ logger.debug("Error while closing PyBoy: %s", error)
289
+
290
+ def get_observation(self):
291
+ board = self.get_board()
292
+ locked_board = self.get_locked_board()
293
+
294
+ holes = self.count_holes(locked_board)
295
+ bumpiness = self.get_bumpiness(locked_board)
296
+ aggregate_height = self.get_aggregate_height(locked_board)
297
+ row_completion = self.get_row_completion(board)
298
+
299
+ observation = {
300
+ "board": board.reshape(1, 18, 10).astype(np.float32),
301
+ "current_piece": self.get_current_piece_onehot(),
302
+ "row_completion": row_completion.astype(np.float32),
303
+ "holes": np.array([holes], dtype=np.float32),
304
+ "bumpiness": np.array([bumpiness], dtype=np.float32),
305
+ "aggregate_height": np.array([aggregate_height], dtype=np.float32),
306
+ }
307
+ if self.include_next_piece:
308
+ observation["next_piece"] = self.get_next_piece_onehot()
309
+
310
+ return observation
311
+
312
+ def _get_piece_column(self):
313
+ cells = self._read_active_cells()
314
+ if not cells:
315
+ return None
316
+ return int(min(col for col, _row, _tile in cells))
317
+
318
+ def _press(self, button, ticks):
319
+ self.emulator.button(button, delay=1)
320
+ self.emulator.tick(ticks, render=self.record_frames)
321
+ self._capture_frame()
322
+
323
+ def _capture_frame(self):
324
+ if not self.record_frames:
325
+ return
326
+ self._record_counter += 1
327
+ if self.record_every > 1 and self._record_counter % self.record_every != 0:
328
+ return
329
+ frame = self.emulator.screen.image.copy()
330
+ if self.max_recorded_frames <= 0 or len(self.recorded_frames) < self.max_recorded_frames:
331
+ self.recorded_frames.append(frame)
332
+
333
+ def capture_screen(self):
334
+ """Render one frame and return a copy of the current screen image.
335
+
336
+ Gameplay ticks use render=False for speed, which leaves the renderer
337
+ buffer stale, so a rendered frame is forced before grabbing it.
338
+ """
339
+ self.emulator.tick(1, render=True)
340
+ return self.emulator.screen.image.copy()
341
+
342
+ def _settle(self):
343
+ """Tick until the board stops changing (past line-clear blinking).
344
+
345
+ Returns the settled board, a screenshot taken while completed rows were
346
+ still visible (only when capture is enabled) and whether completed rows
347
+ were seen at all.
348
+ """
349
+ prev = self.get_locked_board()
350
+ stable = 0
351
+ frames = 0
352
+ captured = None
353
+ saw_complete_rows = False
354
+ while frames < SETTLE_MAX_FRAMES:
355
+ self.emulator.tick(SETTLE_CHUNK, render=self.record_frames)
356
+ self._capture_frame()
357
+ frames += SETTLE_CHUNK
358
+ current = self.get_locked_board()
359
+ if (current.sum(axis=1) == 10).any():
360
+ saw_complete_rows = True
361
+ if self.capture_line_clear and captured is None:
362
+ captured = self.capture_screen()
363
+ if np.array_equal(current, prev):
364
+ stable += 1
365
+ if stable >= SETTLE_STABLE_CHUNKS:
366
+ break
367
+ else:
368
+ stable = 0
369
+ prev = current
370
+ return self.get_locked_board(), captured, saw_complete_rows
371
+
372
+ def _await_line_counter(self, lines_before, max_frames=LINES_CONFIRM_MAX_FRAMES):
373
+ """Wait (bounded) for the game's on-screen line counter to advance."""
374
+ frames = 0
375
+ while frames < max_frames and self.tetris.lines <= lines_before:
376
+ self.emulator.tick(LINES_CONFIRM_CHUNK, render=self.record_frames)
377
+ self._capture_frame()
378
+ frames += LINES_CONFIRM_CHUNK
379
+ return max(0, self.tetris.lines - lines_before)
380
+
381
+ def _await_active_piece(self, max_frames=48):
382
+ """Wait (bounded) for the next piece to spawn after a clear."""
383
+ frames = 0
384
+ while frames < max_frames and not self._read_active_cells():
385
+ self.emulator.tick(SETTLE_CHUNK, render=self.record_frames)
386
+ self._capture_frame()
387
+ frames += SETTLE_CHUNK
388
+
389
+ def step(self, action):
390
+ """
391
+ Action is a placement: rotation (0-3) * 10 + column (0-9).
392
+ The env rotates the piece, moves it to the target column, and hard drops.
393
+ One step = one piece placed.
394
+ """
395
+ target_rotation = action // 10
396
+ target_column = action % 10
397
+
398
+ wrapper_lines_before = self.tetris.lines
399
+
400
+ self.collector.register_action(action)
401
+
402
+ # Apply rotations to reach target rotation
403
+ rotations_needed = (target_rotation - self.piece_rotation) % 4
404
+ for _ in range(rotations_needed):
405
+ self._press("a", ROTATE_TICKS)
406
+ self.collector.register_rotation()
407
+ self.piece_rotation = target_rotation % 4
408
+
409
+ # Move to target column
410
+ for _ in range(MAX_MOVE_ATTEMPTS):
411
+ current_col = self._get_piece_column()
412
+ if current_col is None or current_col == target_column:
413
+ break
414
+ self._press("right" if current_col < target_column else "left", MOVE_TICKS)
415
+
416
+ # Hard drop: press down until the locked board changes
417
+ prev_board = self.get_locked_board()
418
+ for _ in range(MAX_DROP_ATTEMPTS):
419
+ self._press("down", DROP_TICKS)
420
+ current_board = self.get_locked_board()
421
+ if not np.array_equal(current_board, prev_board):
422
+ break
423
+ prev_board = current_board
424
+
425
+ locked_board, captured_frame, saw_complete_rows = self._settle()
426
+
427
+ self.episode_steps += 1
428
+ self.steps_since_last_line_clear += 1
429
+
430
+ board_before = self.previous_locked_board
431
+ piece_locked = not np.array_equal(locked_board, board_before)
432
+
433
+ done = self.game_over(locked_board)
434
+ if not done:
435
+ self._await_active_piece()
436
+
437
+ # Line clears are only counted when the game's own line counter
438
+ # advances. The board cell count is used just as evidence that a clear
439
+ # is in progress (it must not be trusted: on game over the kill screen
440
+ # replaces the board with tiles that are not counted as filled).
441
+ wrapper_delta = max(0, self.tetris.lines - wrapper_lines_before)
442
+ if not done and saw_complete_rows and wrapper_delta == 0:
443
+ wrapper_delta = self._await_line_counter(wrapper_lines_before)
444
+
445
+ catch_up = max(0, self.tetris.lines - self.lines_cleared)
446
+ lines_delta = max(wrapper_delta, catch_up)
447
+
448
+ # Build the observation *after* all ticking so it always reflects the
449
+ # exact state the next action will be applied to.
450
+ observation = self.get_observation()
451
+
452
+ if saw_complete_rows and lines_delta == 0 and not done:
453
+ logger.warning(
454
+ "[LINE_DETECT] Completed rows seen but the game line counter did not advance"
455
+ )
456
+
457
+ if piece_locked:
458
+ self.pieces_placed += 1
459
+ self.piece_rotation = 0
460
+ self.lines_cleared += lines_delta
461
+ if lines_delta > 0:
462
+ self.steps_since_last_line_clear = 0
463
+ self.collector.register_line_clear(lines_delta)
464
+
465
+ current_holes = self.count_holes(locked_board)
466
+ holes_delta = current_holes - self.previous_holes
467
+ current_height = self.get_max_height(locked_board)
468
+ current_aggregate_height = self.get_aggregate_height(locked_board)
469
+ current_bumpiness = self.get_bumpiness(locked_board)
470
+
471
+ if lines_delta > 0:
472
+ new_cells = (locked_board == 1) & (board_before == 0)
473
+ new_cols = np.where(new_cells.any(axis=0))[0]
474
+ placement_col = int(new_cols.min()) if len(new_cols) else target_column
475
+ self.collector.register_piece_placed(placement_col, current_height)
476
+
477
+ logger.info(
478
+ f"[LINE_CLEAR] lines={lines_delta}, total_lines={self.lines_cleared}, "
479
+ f"piece#{self.pieces_placed}"
480
+ )
481
+ if self.capture_line_clear and captured_frame is not None:
482
+ self.line_clear_frames.append(
483
+ (lines_delta, self.lines_cleared, captured_frame)
484
+ )
485
+ elif self.pieces_placed % 10 == 0:
486
+ logger.info(
487
+ f"[PIECE #{self.pieces_placed}] height={current_height}, "
488
+ f"holes={current_holes}, bumpy={current_bumpiness}"
489
+ )
490
+
491
+ reward = self.calculate_lock_reward(
492
+ lines_delta, holes_delta, current_holes,
493
+ current_height, current_aggregate_height, current_bumpiness
494
+ )
495
+
496
+ self.previous_locked_board = locked_board.copy()
497
+ self.previous_holes = current_holes
498
+ self.previous_height = current_height
499
+ self.previous_aggregate_height = current_aggregate_height
500
+ self.previous_bumpiness = current_bumpiness
501
+
502
+ self.collector.update_max_height(current_height)
503
+ self.collector.record_bumpiness(current_bumpiness)
504
+ self.collector.record_holes(current_holes)
505
+ else:
506
+ reward = 0.0
507
+
508
+ truncated = self.steps_since_last_line_clear > self.config.watchdog_steps
509
+ if truncated:
510
+ logger.info(
511
+ "[WATCHDOG] Truncating episode: no line clear in %d steps",
512
+ self.config.watchdog_steps,
513
+ )
514
+
515
+ if done:
516
+ reward = self.config.game_over_penalty
517
+ self.n_deaths += 1
518
+ logger.info(
519
+ f"[EPISODE_END] steps={self.episode_steps}, pieces={self.pieces_placed}, "
520
+ f"lines_cleared={self.lines_cleared}, cum_reward={self.current_score:.2f}"
521
+ )
522
+
523
+ self.current_score += reward
524
+
525
+ info = self.get_info()
526
+
527
+ if done or truncated:
528
+ summary = self.collector.episode_summary(
529
+ episode=self.n_deaths,
530
+ steps=self.episode_steps,
531
+ pieces=self.pieces_placed,
532
+ total_lines=self.lines_cleared,
533
+ total_reward=self.current_score,
534
+ )
535
+ info["episode_summary"] = summary
536
+
537
+ return observation, reward, done, truncated, info
538
+
539
+ def calculate_lock_reward(self, lines_delta, holes_delta, current_holes,
540
+ current_height, aggregate_height, bumpiness):
541
+ config = self.config
542
+ if lines_delta >= 4:
543
+ reward_lines = config.tetris_reward
544
+ else:
545
+ reward_lines = config.line_clear_rewards.get(lines_delta, 0.0)
546
+
547
+ reward_holes = -config.hole_penalty * max(0, holes_delta)
548
+ reward_height = -config.aggregate_height_penalty * aggregate_height
549
+ reward_bumpiness = -config.bumpiness_penalty * bumpiness
550
+
551
+ reward = (
552
+ reward_lines + reward_holes + reward_height + reward_bumpiness
553
+ + config.survival_bonus
554
+ )
555
+
556
+ self.collector.register_reward_breakdown(
557
+ reward_lines=reward_lines,
558
+ reward_holes=reward_holes,
559
+ reward_height=reward_height,
560
+ reward_bumpiness=reward_bumpiness,
561
+ reward_clean=config.survival_bonus,
562
+ reward_total=reward,
563
+ )
564
+
565
+ return reward
566
+
567
+ def game_over(self, board=None):
568
+ return self.tetris.game_over()
569
+
570
+ def get_info(self):
571
+ return {
572
+ "score": self.current_score,
573
+ "lines_cleared": self.lines_cleared,
574
+ "pieces_placed": self.pieces_placed,
575
+ "n_deaths": self.n_deaths,
576
+ }
577
+
578
+ def get_column_height(self, board, col):
579
+ for i in range(len(board)):
580
+ if board[i][col] == 1:
581
+ return len(board) - i
582
+ return 0
583
+
584
+ def get_bumpiness(self, board):
585
+ heights = [self.get_column_height(board, col) for col in range(board.shape[1])]
586
+ return sum(abs(heights[i] - heights[i + 1]) for i in range(len(heights) - 1))
587
+
588
+ def get_aggregate_height(self, board):
589
+ heights = [self.get_column_height(board, col) for col in range(board.shape[1])]
590
+ return sum(heights)
591
+
592
+ def get_max_height(self, board):
593
+ heights = [self.get_column_height(board, col) for col in range(board.shape[1])]
594
+ return max(heights) if heights else 0
595
+
596
+ def count_holes(self, board):
597
+ holes = 0
598
+ for col in range(board.shape[1]):
599
+ column = board[:, col]
600
+ filled = False
601
+ for cell in column:
602
+ if cell == 1:
603
+ filled = True
604
+ elif filled and cell == 0:
605
+ holes += 1
606
+ return holes
607
+
608
+ def get_row_completion(self, board):
609
+ return board.sum(axis=1).astype(np.float32)
@@ -0,0 +1,3 @@
1
+ from . import board, pieces
2
+
3
+ __all__ = ["board", "pieces"]
@@ -0,0 +1,72 @@
1
+ """Pure board-metric helpers shared by the environment and the teacher."""
2
+
3
+ import numpy as np
4
+
5
+ BOARD_HEIGHT = 18
6
+ BOARD_WIDTH = 10
7
+
8
+
9
+ def first_filled_row(board, col):
10
+ column = board[:, col]
11
+ index = int(np.argmax(column))
12
+ if column[index] == 0:
13
+ return BOARD_HEIGHT
14
+ return index
15
+
16
+
17
+ def clear_lines(board):
18
+ """Remove complete rows and return (new_board, lines_cleared)."""
19
+ kept_rows = [row for row in board if row.sum() < BOARD_WIDTH]
20
+ cleared = BOARD_HEIGHT - len(kept_rows)
21
+ if cleared == 0:
22
+ return board, 0
23
+ new_board = np.zeros_like(board)
24
+ if kept_rows:
25
+ new_board[cleared:, :] = np.array(kept_rows, dtype=board.dtype)
26
+ return new_board, cleared
27
+
28
+
29
+ def column_heights(board):
30
+ has_filled = board.any(axis=0)
31
+ first = np.argmax(board, axis=0)
32
+ return np.where(has_filled, BOARD_HEIGHT - first, 0)
33
+
34
+
35
+ def aggregate_height(board):
36
+ return int(column_heights(board).sum())
37
+
38
+
39
+ def count_holes(board):
40
+ above = np.maximum.accumulate(board, axis=0)
41
+ return int(((above == 1) & (board == 0)).sum())
42
+
43
+
44
+ def bumpiness(board):
45
+ heights = column_heights(board)
46
+ return int(np.abs(np.diff(heights)).sum())
47
+
48
+
49
+ def row_transitions(board):
50
+ horizontal = int(np.abs(np.diff(board.astype(np.int64), axis=1)).sum())
51
+ return horizontal + int(board[:, 0].sum()) + int(board[:, -1].sum())
52
+
53
+
54
+ def column_transitions(board):
55
+ vertical = int(np.abs(np.diff(board.astype(np.int64), axis=0)).sum())
56
+ return vertical + int((board[-1, :] == 0).sum())
57
+
58
+
59
+ def wells(board):
60
+ left = np.concatenate(
61
+ [np.ones((BOARD_HEIGHT, 1), dtype=board.dtype), board[:, :-1]], axis=1
62
+ )
63
+ right = np.concatenate(
64
+ [board[:, 1:], np.ones((BOARD_HEIGHT, 1), dtype=board.dtype)], axis=1
65
+ )
66
+ well_cell = (board == 0) & (left == 1) & (right == 1)
67
+ total = 0
68
+ depth = np.zeros(BOARD_WIDTH, dtype=np.int64)
69
+ for row in range(BOARD_HEIGHT):
70
+ depth = np.where(well_cell[row], depth + 1, 0)
71
+ total += int(depth.sum())
72
+ return total
@@ -0,0 +1,72 @@
1
+ """GameBoy Tetris piece metadata.
2
+
3
+ Tile identifiers and rotation tables were calibrated against the emulator, so
4
+ the rotation shapes match what the game actually does (clockwise `A` presses).
5
+ """
6
+
7
+ PIECE_TILES = {128, 129, 130, 131, 132, 133, 134, 136, 137, 138, 139, 143}
8
+
9
+ TILE_TO_PIECE = {
10
+ 129: "J",
11
+ 130: "Z",
12
+ 131: "O",
13
+ 132: "L",
14
+ 133: "T",
15
+ 134: "S",
16
+ 128: "I",
17
+ 136: "I",
18
+ 137: "I",
19
+ 138: "I",
20
+ 139: "I",
21
+ 143: "I",
22
+ }
23
+
24
+ TETROMINO_TYPES = ["L", "J", "I", "O", "Z", "S", "T"]
25
+
26
+ TETROMINO_NUMBERS = {"L": 0, "J": 4, "I": 8, "O": 12, "Z": 16, "S": 20, "T": 24}
27
+
28
+ # Normalized cell offsets (row, col) per rotation index 0-3.
29
+ PIECE_SHAPES = {
30
+ "I": [
31
+ [(0, 0), (0, 1), (0, 2), (0, 3)],
32
+ [(0, 0), (1, 0), (2, 0), (3, 0)],
33
+ [(0, 0), (0, 1), (0, 2), (0, 3)],
34
+ [(0, 0), (1, 0), (2, 0), (3, 0)],
35
+ ],
36
+ "O": [
37
+ [(0, 0), (0, 1), (1, 0), (1, 1)],
38
+ [(0, 0), (0, 1), (1, 0), (1, 1)],
39
+ [(0, 0), (0, 1), (1, 0), (1, 1)],
40
+ [(0, 0), (0, 1), (1, 0), (1, 1)],
41
+ ],
42
+ "T": [
43
+ [(0, 0), (0, 1), (0, 2), (1, 1)],
44
+ [(0, 1), (1, 0), (1, 1), (2, 1)],
45
+ [(0, 1), (1, 0), (1, 1), (1, 2)],
46
+ [(0, 0), (1, 0), (1, 1), (2, 0)],
47
+ ],
48
+ "S": [
49
+ [(0, 1), (0, 2), (1, 0), (1, 1)],
50
+ [(0, 0), (1, 0), (1, 1), (2, 1)],
51
+ [(0, 1), (0, 2), (1, 0), (1, 1)],
52
+ [(0, 0), (1, 0), (1, 1), (2, 1)],
53
+ ],
54
+ "Z": [
55
+ [(0, 0), (0, 1), (1, 1), (1, 2)],
56
+ [(0, 1), (1, 0), (1, 1), (2, 0)],
57
+ [(0, 0), (0, 1), (1, 1), (1, 2)],
58
+ [(0, 1), (1, 0), (1, 1), (2, 0)],
59
+ ],
60
+ "L": [
61
+ [(0, 0), (0, 1), (0, 2), (1, 0)],
62
+ [(0, 0), (0, 1), (1, 1), (2, 1)],
63
+ [(0, 2), (1, 0), (1, 1), (1, 2)],
64
+ [(0, 0), (1, 0), (2, 0), (2, 1)],
65
+ ],
66
+ "J": [
67
+ [(0, 0), (0, 1), (0, 2), (1, 2)],
68
+ [(0, 1), (1, 1), (2, 0), (2, 1)],
69
+ [(0, 0), (1, 0), (1, 1), (1, 2)],
70
+ [(0, 0), (0, 1), (1, 0), (2, 0)],
71
+ ],
72
+ }
netris/registration.py ADDED
@@ -0,0 +1,22 @@
1
+ """Gymnasium registration.
2
+
3
+ The environment requires a GameBoy Tetris ROM (not bundled). Provide it via the
4
+ `rom_path` kwarg or the `NETRIS_ROM` environment variable:
5
+
6
+ import gymnasium as gym
7
+ import netris # registers the environment
8
+
9
+ env = gym.make("Netris/GameBoyTetris-v0", rom_path="path/to/tetris.gb")
10
+ """
11
+
12
+ import gymnasium
13
+
14
+ ENV_ID = "Netris/GameBoyTetris-v0"
15
+
16
+
17
+ def register_env():
18
+ if ENV_ID not in gymnasium.registry:
19
+ gymnasium.register(
20
+ id=ENV_ID,
21
+ entry_point="netris.envs.tetris_env:TetrisEnv",
22
+ )
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: netris
3
+ Version: 0.1.0
4
+ Summary: Gymnasium environment for Game Boy Tetris emulated with PyBoy
5
+ Project-URL: Homepage, https://github.com/javi-aranda/netris
6
+ Author: Javier Aranda
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Javier Aranda
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: gameboy,gymnasium,pyboy,reinforcement-learning,tetris
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
35
+ Requires-Python: >=3.11
36
+ Requires-Dist: gymnasium>=1.0
37
+ Requires-Dist: numpy>=1.23
38
+ Requires-Dist: pyboy>=2.6
39
+ Provides-Extra: dev
40
+ Requires-Dist: pytest>=8.0; extra == 'dev'
41
+ Requires-Dist: ruff>=0.6; extra == 'dev'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # netris
45
+
46
+ A [Gymnasium](https://gymnasium.farama.org/) environment for **Game Boy Tetris**,
47
+ emulated with [PyBoy](https://github.com/Baekalfen/PyBoy).
48
+
49
+ - Placement-based action space: `rotation (0-3) * 10 + column (0-9)` (40 actions).
50
+ - Dict observation: board, current/next piece, row completion, holes,
51
+ bumpiness and aggregate height.
52
+ - Line clears are confirmed against the game's own on-screen counter, so
53
+ phantom clears (e.g. at game over) cannot happen.
54
+ - Optional 1-piece lookahead information via the on-screen NEXT panel
55
+ (`NetrisEnvConfig(include_next_piece=True)`).
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install netris
61
+ ```
62
+
63
+ ## ROM
64
+
65
+ The Game Boy Tetris ROM is **not** distributed with this package. Provide your
66
+ own dump:
67
+
68
+ ```bash
69
+ export NETRIS_ROM=/path/to/tetris.gb
70
+ ```
71
+
72
+ ## Quickstart
73
+
74
+ ```python
75
+ import gymnasium as gym
76
+ import netris # registers "Netris/GameBoyTetris-v0"
77
+
78
+ env = gym.make("Netris/GameBoyTetris-v0") # uses NETRIS_ROM
79
+ obs, info = env.reset(seed=0)
80
+ obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
81
+ env.close()
82
+ ```
83
+
84
+ Or directly:
85
+
86
+ ```python
87
+ from netris import NetrisEnvConfig, TetrisEnv
88
+
89
+ env = TetrisEnv("/path/to/tetris.gb", config=NetrisEnvConfig())
90
+ obs, info = env.reset()
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT. PyBoy is an LGPL-3.0 dependency; the Tetris ROM is copyrighted and must be
96
+ supplied by the user.
@@ -0,0 +1,13 @@
1
+ netris/__init__.py,sha256=x-YwDcn11-Dw6aHqyXYODbn_P6xxSYoW1OBWTfF4AbU,275
2
+ netris/registration.py,sha256=1rCWndD87SoYUUijJQjOWg4tZliwPtwRDdmO-qDWJco,562
3
+ netris/envs/__init__.py,sha256=uewV0daYjhqgUk2MgzAQqYuzKsKILJcSQ1b4cDPoX70,114
4
+ netris/envs/config.py,sha256=zCxx3pMDkhi8J7hq83B4D_WoeVLCypg6P8YHxiyGSUs,1206
5
+ netris/envs/metrics.py,sha256=EazEHOilXYojdSsXyV5cNn07M0B1aCdRgnpsQ1KGiWw,4812
6
+ netris/envs/tetris_env.py,sha256=QzZmN3Nh02NheKqx8jkUMVoEXwlrDWgFkn5NbnYK9eM,23317
7
+ netris/gameboy/__init__.py,sha256=v_TXDysl7cr1uIxcUyBP85MhQjprEBW3HWS6lEOdo1A,59
8
+ netris/gameboy/board.py,sha256=tuLg9XjaeGW3zyRXF1rpYa3NCBF-BG5Jm1GrsoEfKDg,2023
9
+ netris/gameboy/pieces.py,sha256=KThShXIeekR999Xs1nvgGHRKbiWO-jhgB0Sct9NOqrs,1981
10
+ netris-0.1.0.dist-info/METADATA,sha256=9lB5Wv-rlNA4nxBMcpzoV50wItRRbSHnDb-PU5cRswA,3388
11
+ netris-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ netris-0.1.0.dist-info/licenses/LICENSE,sha256=bGmrIvBsrD4zQWJvSgLMhtvGUqeKYO0RxjM_v_RgXFE,1070
13
+ netris-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Aranda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.