Objetivo

Fazer aprendizado por reforço com nosso próprio motor em C++, sem usar ambientes externos como gym-tetris ou uma ROM de NES. O treino roda sem tela, e a tela em Qt só é conectada para ver os resultados.

O motor

  • O tabuleiro tem 10 casas de largura e 26 de altura. As 4 linhas de cima são linhas ocultas onde as peças surgem, então só 22 linhas aparecem na tela e na observação.
  • As peças são sorteadas com 7-bag. Os sete tipos são embaralhados e saem uma vez cada, então nenhuma peça fica muito tempo sem aparecer.
  • Ao girar, ele tenta 7 posições em ordem, da posição original até as posições empurradas para a esquerda, para a direita e para cima, e coloca a peça na primeira que couber.
  • Depois de um movimento, uma rotação ou de ficar parado, a peça desce uma casa sozinha. Por isso, o número de ações disponíveis por peça é igual à altura do tabuleiro.
  • A recompensa é calculada no momento em que a peça trava. A composição da recompensa fica para o próximo post.

C API e ambiente Gym

O motor é uma classe C++, então o Python não consegue chamá-lo diretamente. Ele é encapsulado em 21 funções extern "C" e compilado como uma biblioteca compartilhada (libtetris_c_api.dylib), que o Python carrega com ctypes. Essa biblioteca é compilada sem Qt.

O LocalTetrisEnv monta um ambiente gymnasium com essas funções.

  • Observação: 22×10 casas. Casas vazias são 0, e os blocos são 1 a 7 conforme o tipo. Blocos travados e a peça em movimento não são diferenciados.
  • Ações: 7. Ficar parado, esquerda, direita, girar no sentido horário, girar no sentido anti-horário, descer uma casa, soltar de vez.
  • Recompensa: o valor calculado pelo motor, usado do jeito que vem.

O motor conhece as próximas 5 peças, mas elas não foram incluídas na observação.

Tela em Qt e TCP

Ao abrir o app em Qt (tetris_rl) com --rl-port 5555, um servidor TCP é aberto. O app envia o estado do tabuleiro como uma linha de JSON, e o cliente devolve o número da ação em uma linha. O JSON contém o tabuleiro, o tipo, a posição e a rotação da peça atual e a lista das próximas peças. Esse canal é usado para rodar na tela um modelo treinado ou para conectar os clientes de LLM e VLM que aparecem mais adiante.

O código real desta etapa

A C API guarda um motor em um único handle, recebe o número da ação e avança um passo.

tetris/src/tetris_c_api.cppcpp · 17 linhas
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;
}

O ambiente em Python corta as 4 linhas ocultas do tabuleiro preenchido pelo motor e usa o resto como observação.

local_tetris_env.pypython · 29 linhas
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

O próximo post é "2. DQN por 370 mil passos, zero linhas eliminadas".