
목표
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.cpp
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.py
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줄’입니다.
COMMENTS · CHAPTER 01
C++ 테트리스 엔진을 강화학습 환경으로 감싸기 댓글 0
아직 댓글이 없습니다. 읽다가 궁금한 점이나 보탤 내용을 남겨 주세요.
댓글을 등록하려면 구글로 시작하기를 눌러 닉네임과 메일 주소를 등록해 주세요.
구글로 시작하기