목표

gym-tetris나 NES ROM 같은 외부 환경을 쓰지 않고, 직접 만든 C++ 엔진으로 강화학습을 합니다. 학습은 화면 없이 돌리고, Qt 화면은 결과를 볼 때만 붙입니다.

엔진

  • 판은 가로 10칸, 세로 26칸입니다. 위 4줄은 블록이 나오는 숨은 줄이라 화면과 관측에는 22줄만 보입니다.
  • 블록은 7-bag으로 뽑습니다. 일곱 종류를 한 번씩 섞어 내보내서 같은 블록이 오래 안 나오는 일이 없습니다.
  • 회전할 때는 제자리부터 좌우·위로 민 자리까지 7곳을 차례로 시도해 처음 들어가는 곳에 놓습니다.
  • 이동, 회전, 가만히 있기 뒤에는 블록이 한 칸 저절로 내려옵니다. 그래서 한 블록에 쓸 수 있는 행동 수는 판 높이만큼입니다.
  • 블록이 굳는 순간 보상을 계산합니다. 보상 구성은 다음 글에서 다룹니다.

C API와 Gym 환경

엔진은 C++ 클래스라 파이썬에서 바로 부를 수 없습니다. extern "C" 함수 21개로 감싸 공유 라이브러리(libtetris_c_api.dylib)로 빌드하고, 파이썬은 ctypes로 불러옵니다. 이 라이브러리는 Qt 없이 빌드됩니다.

LocalTetrisEnv는 이 함수들로 gymnasium 환경을 만듭니다.

  • 관측: 22×10 칸. 빈칸은 0, 블록은 종류에 따라 1~7입니다. 굳은 블록과 움직이는 블록을 구분하지 않습니다.
  • 행동: 7개. 가만히, 왼쪽, 오른쪽, 시계 회전, 반시계 회전, 한 칸 내리기, 바로 떨어뜨리기.
  • 보상: 엔진이 계산한 값을 그대로 씁니다.

엔진은 다음 블록 5개를 알고 있지만 관측에는 넣지 않았습니다.

Qt 화면과 TCP

Qt 앱(tetris_rl)을 --rl-port 5555로 띄우면 TCP 서버가 열립니다. 앱은 판 상태를 한 줄 JSON으로 보내고, 클라이언트는 행동 번호를 한 줄로 돌려줍니다. JSON에는 판, 현재 블록의 종류·위치·회전, 다음 블록 목록이 들어 있습니다. 학습한 모델을 화면에서 돌리거나 뒤에 나오는 LLM·VLM 클라이언트를 붙일 때 이 통로를 씁니다.

이 단계의 실제 코드

C API는 핸들 하나에 엔진을 담고, 행동 번호를 받아 한 스텝 진행합니다.

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

파이썬 환경은 엔진이 채운 판에서 숨은 4줄을 잘라 관측으로 씁니다.

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

다음 글은 ‘2. DQN 37만 스텝, 지운 줄은 0줄’입니다.