메인 콘텐츠로 건너뛰기
page thumbnail

테트리스 만들기 기초 개념과 구현 방법

요약

개요

테트리스 만들기는 간단한 퍼즐 게임을 직접 구현하면서 프로그래밍 기본기를 탄탄하게 다질 수 있는 대표적인 프로젝트이다. 겉보기에는 단순한 블록 맞추기 게임처럼 보이지만, 실제로 구현하려면 좌표계, 충돌 판정, 게임 루프, 입력 처리 등 다양한 개념을 이해해야 한다.

Generated Image

이 글은 특정 언어나 프레임워크에 종속되지 않고, 공통적으로 적용되는 개념을 중심으로 테트리스의 구조를 설명한다. 이를 통해 JavaScript, Python, C#, Java 등 어떤 언어로도 응용할 수 있도록 하는 것이 목표이다. 완벽한 상용 수준을 만드는 것보다, "동작하는 테트리스"를 직접 구현할 수 있는 길을 보여주는 데 초점을 둔다.

테트리스의 기본 규칙 이해하기

테트리스는 위에서 떨어지는 블록을 좌우로 이동시키고 회전시켜, 빈틈 없이 가로 한 줄을 채우면 그 줄이 사라지는 게임이다. 화면은 일반적으로 세로로 긴 직사각형 영역(플레이 필드 또는 보드)으로 구성되며, 예시로 가로 10칸, 세로 20칸이 자주 사용된다.

게임에 등장하는 블록은 테트로미노(Tetromino)라고 불리며, 정확히 네 개의 정사각형으로 이루어진 7가지 모양(I, O, T, S, Z, J, L)이 사용된다. 각 블록은 한 칸씩 아래로 떨어지며, 플레이어는 일정 시간 안에 방향키 등을 사용해 블록을 좌우로 움직이거나 회전시켜 위치를 조정할 수 있다. 블록이 바닥이나 다른 블록에 닿아 더 이상 내려갈 수 없으면 그 자리에 고정되고, 그 결과로 완전히 채워진 가로 줄이 있으면 그 줄은 삭제되며 점수가 올라간다. 블록이 쌓여 맨 위까지 차면 게임 오버가 된다.

전체 구조 한눈에 보기

테트리스를 구현할 때는 보통 "보드를 어떻게 표현할 것인가"와 "블록을 어떻게 표현하고 움직일 것인가"를 먼저 정한다. 일반적인 구조는 2차원 배열로 보드를 저장하고, 현재 떨어지고 있는 블록을 별도의 자료 구조로 관리하는 방식이다.

이 구조 위에 매 프레임마다 반복해서 실행되는 게임 루프를 올려놓는다. 게임 루프는 시간 흐름에 따라 블록을 아래로 떨어뜨리고, 사용자 입력(키보드, 터치 등)을 읽어 블록을 이동 및 회전시키며, 충돌 여부를 확인하고, 줄이 채워졌는지 검사한다. 마지막으로 화면 렌더링 단계에서 보드와 현재 블록의 상태를 화면에 그려준다.

보드(필드) 데이터 구조 설계

보드는 테트리스의 핵심 데이터 구조로, 보통 2차원 배열로 표현한다. 예를 들어, 높이가 20, 너비가 10인 보드는 board[row][col] 형태로 표현할 수 있다. 각 칸에는 "비어 있음" 또는 "특정 색상의 블록이 차 있음"을 나타내는 값이 들어간다. 비어 있음을 0, 블록을 1이나 색상 ID 등의 숫자로 표현하는 방식이 많다.

중요한 점은, 고정된 블록(이미 떨어져서 바닥에 붙은 블록)만 보드에 저장한다는 것이다. 현재 떨어지고 있는 블록은 별도의 변수로 관리하고, 화면을 그릴 때만 "보드 + 현재 블록"을 합친 결과를 보여주는 것이 구현을 단순하게 만든다. 이렇게 하면 충돌 판정이나 줄 삭제 처리가 훨씬 명확해진다.

테트로미노(블록) 표현 방법

테트로미노는 4개의 정사각형으로 이루어진 다양한 형태의 블록을 의미한다. 구현에서는 보통 각 블록을 작은 2차원 패턴으로 표현한다. 예를 들어, I 블록은 4×4 배열, O 블록은 2×2 배열 등으로 표현할 수 있으며, 배열 안에서 1은 블록이 있는 칸, 0은 빈 칸을 의미한다.

또 다른 방법은 블록을 "좌표들의 집합"으로 표현하는 것이다. 예를 들어, 블록의 기준점(피봇)을 (0, 0)으로 두고 그 주변의 상대 좌표들로 모양을 표현한다. 이 방식은 회전 계산을 수학적으로 처리하기 쉽다. 어느 방식이든 중요한 것은, 블록의 모양과 회전 상태를 쉽게 바꿔가며 현재 위치에 배치할 수 있어야 한다는 점이다.

블록 회전 구현 아이디어

블록 회전은 테트리스 구현에서 처음에 헷갈리기 쉬운 부분이다. 2차원 배열로 블록을 표현하는 경우, 90도 회전은 배열 인덱스를 바꾸는 것으로 구현할 수 있다. 예를 들어, 시계 방향 회전은 rotated[x][y] = original[h-1-y][x] 같은 식으로 계산할 수 있다(언어마다 인덱스 순서가 다를 수 있다).

회전이 제대로 이루어졌다면, 다음으로 해야 할 일은 회전한 블록이 보드 밖으로 나가거나 다른 블록과 겹치지 않는지 확인하는 것이다. 회전 후에 충돌이 발생하면, 그 회전을 무효화하거나, 옆으로 한 칸 밀어보는 등의 보정(일명 "월 킥")을 시도할 수도 있다. 단순 구현 단계에서는 회전 후 충돌이 있으면 "회전 실패"로 처리해도 충분히 게임다운 경험을 제공할 수 있다.

충돌 판정과 고정 처리

충돌 판정은 "현재 블록을 한 칸 아래(또는 왼쪽/오른쪽)로 움직였을 때, 보드 밖이나 기존 블록과 겹치는가?"를 확인하는 작업이다. 구현 방법은 단순하다. 현재 블록의 각 칸에 대해 새로운 위치의 보드 좌표를 계산하고, 그 좌표가 보드 범위를 벗어나거나 이미 채워진 칸과 겹치는지 검사하면 된다.

만약 아래로 한 칸 움직였을 때 충돌이 발생한다면, 그 직전 위치가 블록이 고정되는 위치가 된다. 이때 현재 블록의 각 칸을 보드 배열에 기록하여 "고정 블록"으로 만든다. 그 다음에는 새 블록을 상단에서 생성하여 다시 게임을 진행한다. 이 일련의 과정이 끊임없이 반복되면서 테트리스 특유의 흐름이 만들어진다.

한 줄 지우기(line clear) 로직

줄 지우기는 테트리스의 재미를 결정하는 핵심 요소이다. 구현 방식은 비교적 단순하다. 모든 행을 위에서 아래로(또는 반대로) 검사하면서, 해당 행의 모든 칸이 비어 있지 않으면 그 행을 "삭제 대상"으로 판단한다.

삭제 대상 행을 찾았다면, 그 행을 제거하고 위에 있는 블록들을 한 줄씩 내려야 한다. 배열로 표현할 경우, 삭제된 행 위에 있는 행들을 아래로 복사하거나, 삭제된 행을 새 빈 행으로 교체하고 나머지 행들을 재배치하는 방식 등을 사용할 수 있다. 여러 줄이 동시에 지워지는 경우에는 지워진 줄 수에 따라 점수와 연속 콤보 보너스를 주는 규칙을 추가할 수도 있다.

점수, 레벨, 난이도 조절

기본 버전의 테트리스에서도 점수 시스템을 간단하게라도 넣어주면 게임의 동기 부여가 크게 올라간다. 가장 많이 쓰이는 방식은, 한 줄을 지울 때마다 일정 점수를 주고, 한 번에 여러 줄을 지우면 더 많은 점수를 부여하는 것이다. 예를 들어 1줄, 2줄, 3줄, 4줄 삭제에 각각 다른 점수를 매길 수 있다.

난이도 조절은 블록이 떨어지는 속도를 점점 빠르게 만드는 방식으로 구현한다. 일반적인 방법은 "레벨"을 두고, 특정 점수 또는 지운 줄 수에 도달할 때마다 레벨을 올리며, 레벨이 올라갈수록 블록이 자동으로 내려가는 간격(타이머)을 줄이는 것이다. 초보자용 구현에서는 단순히 몇 초마다 한 칸 떨어지게 했다가, 시간이 지날수록 그 간격을 조금씩 줄이는 정도로도 충분하다.

게임 루프와 시간 처리

게임 루프는 테트리스가 끊임없이 움직이고 반응하도록 만드는 중심 구조이다. 일반적으로 다음과 같은 순서가 반복된다: 입력 처리 → 논리 업데이트(중력, 충돌, 줄 삭제, 점수 계산) → 렌더링. 이 루프는 일정한 시간 간격으로 계속 호출되거나, 프레임 단위로 반복된다.

시간 처리는 언어나 플랫폼에 따라 다르지만, 보통 "마지막으로 블록이 떨어진 시간"을 기록해 두고, 현재 시간과 비교해 일정 시간(예: 1초)이 지났다면 블록을 한 칸 아래로 내리는 방식이 많이 사용된다. 이와 동시에, 사용자의 좌우 이동, 회전 등은 프레임마다 입력을 확인해 즉시 반영할 수 있다. 웹 환경에서는 requestAnimationFrame, 데스크톱 환경에서는 타이머나 게임 엔진의 업데이트 메서드 등을 활용한다.

입력 처리와 사용자 조작

입력 처리는 사용자가 블록을 얼마나 자연스럽게 컨트롤할 수 있는지를 좌우한다. 가장 기본적으로는 왼쪽, 오른쪽 이동, 회전(보통 위쪽 화살표 또는 Z/X 키), 빠른 하강(소프트 드롭), 즉시 바닥으로 떨어뜨리기(하드 드롭) 등을 지원할 수 있다.

구현에서는 각 키 입력을 감지하고, 그에 맞춰 블록의 위치나 회전을 변경한 뒤, 다시 충돌 판정을 수행해 움직임이 유효한지 확인한다. 예를 들어 왼쪽 화살표를 누를 때마다 블록의 x 좌표를 1 줄이고, 그 결과가 보드 밖이거나 다른 블록과 겹치면 원래 위치로 되돌리는 방식이다. 하드 드롭의 경우에는 충돌이 발생하기 직전까지 반복해서 아래로 내리며, 최종 위치에 곧바로 고정하는 로직으로 구현할 수 있다.

화면 렌더링 방식

화면 렌더링은 데이터 구조에 저장된 정보를 사람이 볼 수 있는 형태로 바꾸는 단계이다. 콘솔, 2D 그래픽, 웹 캔버스, 게임 엔진 등 어떤 환경이든 핵심 아이디어는 같다. 먼저 보드를 그리며, 고정된 블록들을 각 칸의 색상 또는 기호를 이용해 표시한다. 그 위에 아직 고정되지 않은 현재 블록을 겹쳐 그린다.

렌더링 시에는 보통 이중 버퍼링이나 화면 지우기 후 다시 그리기 방식으로 깜빡임을 줄인다. 콘솔 환경이라면 전체 화면을 자주 지우는 대신, 가능하면 변경된 부분만 다시 그리는 최적화도 생각해 볼 수 있지만, 학습 단계에서는 매 프레임 전체를 다시 그려도 큰 문제는 없다. 블록 색상, 격자선, 배경 등을 적절히 꾸미면 완성도가 크게 올라간다.

간단한 의사코드로 보는 전체 흐름

아래는 언어에 관계없이 테트리스의 흐름을 이해하기 위한 매우 단순화된 의사코드 예시이다.

초기화:
  보드 생성(모든 칸 비어 있음)
  새 블록 생성
  점수, 레벨, 타이머 초기화

게임 루프 반복:
  입력 처리:
    왼쪽/오른쪽/회전/하강 키 확인 후 블록 이동 및 회전
    이동 또는 회전 후 충돌이면 되돌리기

  시간에 따른 자동 하강:
    현재 시간 - 마지막 하강 시간 >= 속도라면
      블록을 한 칸 아래로 이동
      만약 충돌이면
        한 칸 위로 되돌리고
        블록을 보드에 고정
        줄이 꽉 찼는지 검사 후 삭제 및 점수 계산
        새 블록 생성
        새 블록이 생성 즉시 충돌하면 게임 오버
      마지막 하강 시간 갱신

  렌더링:
    보드와 현재 블록 상태를 화면에 그리기

이 흐름에 따라 각 부분을 실제 언어로 옮겨 쓰면 기본적인 테트리스가 완성된다. 이후에는 효과음, 애니메이션, 고스트 블록 표시(떨어질 위치 미리 보여주기), 홀드 기능(블록 한 번 저장하기) 등 다양한 기능을 추가하며 프로젝트를 확장해 볼 수 있다.

마무리 및 확장 아이디어

테트리스 만들기는 "단순한 규칙을 가진 게임 하나를 끝까지 구현해 본 경험"을 제공한다는 점에서 의미가 크다. 이 과정에서 배열, 조건문, 반복문, 함수 분리, 상태 관리, 시간 처리 등 기초 프로그래밍 기술이 자연스럽게 훈련된다.

기본 버전이 완성되면, 인공지능 플레이어를 만들어 자동으로 테트리스를 두게 해 보는 등 한 단계 확장된 프로젝트도 도전할 수 있다. 이때는 줄 삭제, 구멍(빈 칸), 높이 등을 기준으로 한 평가 함수를 설계하고, 가능한 모든 위치와 회전을 탐색해 최적의 선택을 찾는 알고리즘을 구현하게 된다. 이런 확장을 통해 테트리스는 단순한 게임 만들기를 넘어, 알고리즘과 AI까지 연계되는 흥미로운 학습 플랫폼이 될 수 있다.

예제: 브라우저에서 실행되는 최소 테트리스 구현

아래는 웹 브라우저에서 바로 실행할 수 있는, 가장 기본적인 테트리스 구현 예제이다. 하나의 index.html 파일에 HTML, CSS, JavaScript를 모두 담은 형태로 제공하며, 파일을 저장한 뒤 브라우저에서 열면 게임이 동작한다. 방향키(← → ↓)와 위쪽 화살표(회전)를 사용해 조작할 수 있다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>간단 테트리스</title>
  <style>
    body {
      background: #111;
      color: #eee;
      font-family: sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-top: 20px;
    }

    h1 {
      margin-bottom: 10px;
    }

    #game {
      border: 2px solid #555;
      background: #000;
    }

    #info {
      margin-top: 10px;
      text-align: center;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <h1>간단 테트리스</h1>
  <canvas id="game" width="200" height="400"></canvas>
  <div id="info">
    <div>← → : 좌우 이동 / ↑ : 회전 / ↓ : 빠른 하강</div>
    <div>점수: <span id="score">0</span></div>
    <div id="status"></div>
  </div>

  <script>
    // ===== 기본 설정 =====
    const COLS = 10;
    const ROWS = 20;
    const BLOCK_SIZE = 20;        // 캔버스에서 한 칸 크기
    const canvas = document.getElementById('game');
    const ctx = canvas.getContext('2d');

    const scoreEl = document.getElementById('score');
    const statusEl = document.getElementById('status');

    // 보드: ROWS x COLS, 0 = 비어 있음
    const board = Array.from({ length: ROWS }, () =>
      Array(COLS).fill(0)
    );

    // 7가지 테트로미노 정의 (각각 4x4 형태, 숫자는 색상 ID)
    const SHAPES = {
      I: [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
      ],
      O: [
        [2, 2],
        [2, 2]
      ],
      T: [
        [0, 3, 0],
        [3, 3, 3],
        [0, 0, 0]
      ],
      S: [
        [0, 4, 4],
        [4, 4, 0],
        [0, 0, 0]
      ],
      Z: [
        [5, 5, 0],
        [0, 5, 5],
        [0, 0, 0]
      ],
      J: [
        [6, 0, 0],
        [6, 6, 6],
        [0, 0, 0]
      ],
      L: [
        [0, 0, 7],
        [7, 7, 7],
        [0, 0, 0]
      ]
    };

    // 색상 팔레트 (ID 0은 비어 있음)
    const COLORS = {
      0: '#000000',
      1: '#00f0f0',
      2: '#f0f000',
      3: '#a000f0',
      4: '#00f000',
      5: '#f00000',
      6: '#0000f0',
      7: '#f0a000'
    };

    let currentPiece = null;
    let score = 0;
    let dropInterval = 700; // 자동 하강 간격(ms)
    let lastDropTime = 0;
    let gameOver = false;

    // ===== 유틸 함수 =====
    function randomPiece() {
      const types = Object.keys(SHAPES);
      const type = types[Math.floor(Math.random() * types.length)];
      return {
        shape: SHAPES[type].map(row => [...row]), // 복사
        x: 3,
        y: 0
      };
    }

    function rotate(shape) {
      const h = shape.length;
      const w = shape[0].length;
      const rotated = Array.from({ length: w }, () => Array(h).fill(0));

      for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
          rotated[x][h - 1 - y] = shape[y][x];
        }
      }
      return rotated;
    }

    function collision(piece, offsetX, offsetY, newShape = null) {
      const shape = newShape || piece.shape;
      for (let y = 0; y < shape.length; y++) {
        for (let x = 0; x < shape[y].length; x++) {
          const cell = shape[y][x];
          if (!cell) continue;

          const newX = piece.x + offsetX + x;
          const newY = piece.y + offsetY + y;

          // 보드 밖?
          if (newX < 0 || newX >= COLS || newY >= ROWS) {
            return true;
          }
          // 위쪽 영역은 보드 검사 생략 (y < 0)
          if (newY < 0) continue;

          // 다른 블록과 충돌?
          if (board[newY][newX] !== 0) {
            return true;
          }
        }
      }
      return false;
    }

    function mergePieceToBoard(piece) {
      piece.shape.forEach((row, y) => {
        row.forEach((value, x) => {
          if (value) {
            const boardY = piece.y + y;
            const boardX = piece.x + x;
            if (boardY >= 0) {
              board[boardY][boardX] = value;
            }
          }
        });
      });
    }

    function clearLines() {
      let cleared = 0;
      for (let y = ROWS - 1; y >= 0; y--) {
        if (board[y].every(cell => cell !== 0)) {
          // 한 줄 삭제
          board.splice(y, 1);
          board.unshift(Array(COLS).fill(0));
          cleared++;
          y++; // 같은 y를 다시 검사
        }
      }
      if (cleared > 0) {
        // 간단 점수 규칙: 줄 수^2 * 100
        score += (cleared * cleared) * 100;
        scoreEl.textContent = score;
      }
    }

    // ===== 그리기 =====
    function drawCell(x, y, value) {
      ctx.fillStyle = COLORS[value];
      ctx.fillRect(
        x * BLOCK_SIZE,
        y * BLOCK_SIZE,
        BLOCK_SIZE,
        BLOCK_SIZE
      );
      ctx.strokeStyle = '#222';
      ctx.strokeRect(
        x * BLOCK_SIZE,
        y * BLOCK_SIZE,
        BLOCK_SIZE,
        BLOCK_SIZE
      );
    }

    function drawBoard() {
      for (let y = 0; y < ROWS; y++) {
        for (let x = 0; x < COLS; x++) {
          drawCell(x, y, board[y][x]);
        }
      }
    }

    function drawPiece(piece) {
      piece.shape.forEach((row, y) => {
        row.forEach((value, x) => {
          if (value && piece.y + y >= 0) {
            drawCell(piece.x + x, piece.y + y, value);
          }
        });
      });
    }

    function render() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      drawBoard();
      if (currentPiece) {
        drawPiece(currentPiece);
      }
    }

    // ===== 게임 진행 =====
    function spawnPiece() {
      currentPiece = randomPiece();
      // 생성하자마자 충돌이면 게임 종료
      if (collision(currentPiece, 0, 0)) {
        gameOver = true;
        statusEl.textContent = '게임 오버 (F5로 다시 시작)';
      }
    }

    function drop() {
      if (!currentPiece || gameOver) return;

      if (!collision(currentPiece, 0, 1)) {
        currentPiece.y++;
      } else {
        // 더 내려갈 수 없으면 고정
        mergePieceToBoard(currentPiece);
        clearLines();
        spawnPiece();
      }
    }

    function update(time) {
      if (gameOver) {
        render();
        return;
      }

      if (!lastDropTime) {
        lastDropTime = time;
      }

      const delta = time - lastDropTime;
      if (delta > dropInterval) {
        drop();
        lastDropTime = time;
      }

      render();
      requestAnimationFrame(update);
    }

    // ===== 입력 처리 =====
    document.addEventListener('keydown', (e) => {
      if (!currentPiece || gameOver) return;

      if (e.key === 'ArrowLeft') {
        if (!collision(currentPiece, -1, 0)) {
          currentPiece.x--;
        }
      } else if (e.key === 'ArrowRight') {
        if (!collision(currentPiece, 1, 0)) {
          currentPiece.x++;
        }
      } else if (e.key === 'ArrowDown') {
        // 소프트 드롭
        if (!collision(currentPiece, 0, 1)) {
          currentPiece.y++;
          score += 1; // 조금 점수
          scoreEl.textContent = score;
        }
      } else if (e.key === 'ArrowUp') {
        // 회전
        const rotated = rotate(currentPiece.shape);
        if (!collision(currentPiece, 0, 0, rotated)) {
          currentPiece.shape = rotated;
        }
      }
    });

    // ===== 시작 =====
    spawnPiece();
    requestAnimationFrame(update);
  </script>
</body>
</html>

위 코드는 다음 요소들을 포함해, 앞에서 설명한 개념을 실제 코드로 옮긴 최소 구현 예시이다.

  • 10×20 크기의 보드를 2차원 배열로 표현

  • 7가지 테트로미노를 작은 배열 패턴으로 정의

  • 방향키 입력을 이용한 좌우 이동, 회전, 빠른 하강

  • 충돌 판정 후 블록 고정 및 줄 삭제, 점수 증가

  • requestAnimationFrame을 활용한 게임 루프와 자동 하강

이 기본 버전이 동작하는 것을 확인한 뒤, 블록 떨어지는 속도를 점점 빠르게 하거나, 하드 드롭, 다음 블록 미리 보기, 고스트 블록, 효과음 등 기능을 점진적으로 추가해 보며 확장해 나갈 수 있다.