Objetivo

Hacer aprendizaje por refuerzo con nuestro propio motor en C++, sin usar entornos externos como gym-tetris o una ROM de NES. El entrenamiento se ejecuta sin pantalla, y la pantalla de Qt solo se conecta para ver los resultados.

El motor

  • El tablero tiene 10 casillas de ancho y 26 de alto. Las 4 filas superiores son filas ocultas donde aparecen las piezas, así que en la pantalla y en la observación solo se ven 22 filas.
  • Las piezas se sacan con un 7-bag. Se barajan los siete tipos y se reparte cada uno una vez, de modo que ninguna pieza deja de salir durante mucho tiempo.
  • Al girar, prueba por orden 7 posiciones, desde el sitio original hasta los sitios desplazados a izquierda, derecha y arriba, y coloca la pieza en la primera en la que cabe.
  • Después de un movimiento, un giro o no hacer nada, la pieza baja una fila por sí sola. Por eso, el número de acciones disponibles por pieza es igual a la altura del tablero.
  • La recompensa se calcula en el momento en que la pieza se fija. La composición de la recompensa se trata en la siguiente entrada.

API en C y entorno Gym

El motor es una clase de C++, así que Python no puede llamarlo directamente. Se envuelve en 21 funciones extern "C" y se compila como biblioteca compartida (libtetris_c_api.dylib), que Python carga con ctypes. Esta biblioteca se compila sin Qt.

LocalTetrisEnv crea un entorno gymnasium a partir de estas funciones.

  • Observación: 22×10 casillas. Las casillas vacías son 0 y los bloques, 1~7 según el tipo. No se distingue entre bloques fijados y la pieza en movimiento.
  • Acciones: 7. No hacer nada, izquierda, derecha, giro horario, giro antihorario, bajar una fila y dejar caer de golpe.
  • Recompensa: se usa tal cual el valor calculado por el motor.

El motor conoce las 5 piezas siguientes, pero no se incluyeron en la observación.

Pantalla de Qt y TCP

Al lanzar la app de Qt (tetris_rl) con --rl-port 5555, se abre un servidor TCP. La app envía el estado del tablero como una línea de JSON y el cliente devuelve un número de acción en una línea. El JSON contiene el tablero, el tipo, la posición y el giro de la pieza actual, y la lista de piezas siguientes. Este canal se usa para ejecutar en pantalla un modelo entrenado o para conectar los clientes de LLM y VLM que aparecen más adelante.

El código real de esta etapa

La API en C guarda un motor en un único handle, recibe un número de acción y avanza un paso.

tetris/src/tetris_c_api.cppcpp · 17 líneas
struct TetrisHandle {
    TetrisEngine engine;
    std::vector<uint8_t> render_grid;
    explicit TetrisHandle(const TetrisEngineConfig& cfg) : engine(cfg) {}
};

// Control
void tetris_set_seed(TetrisHandle* h, uint64_t seed) { h->engine.set_seed(seed); }

void tetris_reset(TetrisHandle* h) { h->engine.reset(); }

// Step returns 1 if alive, 0 if game over
int tetris_step(TetrisHandle* h, int action) {
    const auto a = static_cast<TetrisAction>(static_cast<int8_t>(action));
    const bool alive = h->engine.step(a);
    return alive ? 1 : 0;
}

El entorno de Python recorta las 4 filas ocultas del tablero que rellena el motor y usa el resto como observación.

local_tetris_env.pypython · 29 líneas
def _obs(self) -> np.ndarray:
    written = int(self._lib._dll.tetris_fill_render_grid(self._h, self._buf, self._w * self._hgt))
    if written <= 0:
        raise RuntimeError("tetris_fill_render_grid() 실패")
    arr = np.frombuffer(self._buf, dtype=np.uint8, count=self._w * self._hgt)
    arr = arr.reshape(self._hgt, self._w)
    visible = arr[self._hidden :, :]  # drop hidden rows
    return visible[:, :, None]

def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None):
    super().reset(seed=seed)
    if seed is not None:
        self._lib._dll.tetris_set_seed(self._h, int(seed))
    self._lib._dll.tetris_reset(self._h)
    return self._obs(), {}

def step(self, action: int):
    alive = int(self._lib._dll.tetris_step(self._h, int(action)))
    reward = float(self._lib._dll.tetris_last_reward(self._h))
    terminated = (alive == 0) or (int(self._lib._dll.tetris_game_over(self._h)) == 1)
    truncated = False
    info = {
        "lines_cleared_total": int(self._lib._dll.tetris_lines_cleared_total(self._h)),
        "piece_index": int(self._lib._dll.tetris_piece_index(self._h)),
        "stack_height": int(self._lib._dll.tetris_stack_height(self._h)),
        "bumpiness": int(self._lib._dll.tetris_bumpiness(self._h)),
        "holes": int(self._lib._dll.tetris_holes(self._h)),
    }
    return self._obs(), reward, terminated, truncated, info

La siguiente entrada es "2. DQN durante 370,000 pasos y 0 líneas borradas".