지그재그에 “포치의 선물가게”를 오픈하며

안녕하세요, 카카오스타일 지그재그 서비스 FE팀의 제이슨입니다.

올해 저희는 지그재그 앱 내에 포치의 선물가게라는 게임 서비스를 오픈했습니다. 이 글에서는 포치의 선물가게 게임을 웹 환경에서 구현하면서 겪었던 기술적 선택들과 그 과정에서 했던 고민을 공유해보려고 합니다.

포치의 선물가게 게임이란?

gameplay

포치의 선물가게 게임은 동일한 과일 두 개가 부딪히면 더 큰 과일로 합쳐지는 방식의 퍼즐형 물리 게임입니다. 하늘에서 과일이 하나씩 떨어지고, 플레이어는 이를 좌우로 이동시키며 상자 안에 쌓아가는 구조로 게임이 진행됩니다.

처음에는 씨앗처럼 작은 요소부터 시작해 점점 더 큰 요소로 성장하게 되고, 가장 마지막 단계인 별사탕까지 만들게 되면 클리어하게 됩니다.

단순한 규칙처럼 보이지만, 요소의 크기나 무게, 충돌 타이밍에 따라 예상치 못한 상황이 생기기도 해 은근히 몰입하게 되는 매력이 있습니다.

기술 스택과 구현 방향

지그재그 웹 프론트엔드 환경은 React + TypeScript + Next.js 기반으로 구성되어 있으며, 이번에 만든 게임 역시 지그재그 앱 내에서 동작하는 웹뷰 기반 서비스였기 때문에, 별도의 독립 앱으로 분리하지 않고 기존 서비스 안에서 구현하는 방향으로 진행했습니다.

물리엔진을 사용한 게임을 직접 구현하게 된 건 처음이었기 때문에, 먼저 오픈소스로 공개된 여러 수박게임 클론들을 분석하며 구조를 파악했습니다. 그중 대부분이 2D 물리엔진인 Matter.js를 활용하고 있다는 점을 확인했고, 이를 기반으로 MVP를 빠르게 구성해보기로 결정했습니다.

Canvas 위에 물리 월드 구성

과일, 벽, 바닥은 각각 Matter.js의 Bodies.circle, Bodies.rectangle 등을 사용해 생성하고, World.add를 통해 물리 월드에 등록했습니다. Matter.js가 자체적으로 중력, 반발력, 충돌 등을 시뮬레이션해주는 덕분에, 간단한 설정만으로도 과일이 자연스럽게 떨어지고 굴러가는 느낌을 구현할 수 있었습니다.

게임 조건 처리 로직 (낙하, 병합, 충돌 판정)

사용자가 화면을 터치하거나 클릭하면, 그 위치를 기준으로 다음에 떨어질 과일의 위치를 실시간으로 이동시킬 수 있도록 구현했습니다.

입력이 끝나는 시점(손을 떼는 시점)에는 해당 위치로 과일을 떨어뜨리고, Matter.js의 물리 시뮬레이션을 통해 자연스럽게 낙하와 충돌이 발생합니다.

이후 collisionStart 충돌 이벤트를 통해 과일끼리의 충돌을 감지하고, 두 과일이 같은 종류일 경우 기존 두 과일을 제거하고 같은 위치에 다음 단계의 과일이 새롭게 생성되도록 처리했습니다.

이처럼 과일 병합, 게임 클리어 조건, 게임오버 판정 등 게임 진행에 필요한 주요 로직은 Matter.js 내부에서 처리하도록 구성했습니다.

React를 통한 게임 상태 표현

게임 로직은 모두 Matter.js에서 처리하고, 그 결과를 사용자에게 보여주는 역할은 React가 담당합니다.

  • Matter.js에서 다음에 등장할 과일을 결정하면, React는 해당 정보를 받아 상단 UI에 미리보기 형태로 보여줍니다.
  • 과일이 병합되거나 점수가 올라갈 경우, React는 게임 상태를 기반으로 현재 점수와 게임 상황을 실시간으로 업데이트합니다.
  • 수박이 만들어지거나 상자 위까지 과일이 쌓이는 등 게임오버 조건이 충족되면, Matter.js가 해당 상태를 판별합니다. 그러면 React가 클리어 또는 게임오버 화면을 보여줍니다.

사용자가 재시작 버튼을 클릭하면, React가 해당 이벤트를 Matter.js로 전달하여 물리 월드를 초기화하고 게임을 새로 시작합니다.

이처럼 React는 게임의 판단이나 로직에는 직접 관여하지 않고, Matter.js가 판단한 결과를 사용자에게 어떻게 보여줄지(UI)와, 사용자의 입력을 다시 Matter.js로 전달하는 연결 지점 역할만 담당하도록 구조를 분리했습니다.

덕분에 물리 연산과 렌더링 사이의 책임이 명확해졌고, 게임 로직과 UI가 서로 영향을 주지 않도록 관리하기 수월했습니다.

initial

Phaser.js로의 전환

Matter.js는 기본적인 게임 플레이를 구현하는 데에는 충분했습니다. 중력, 충돌과 같은 물리 연산은 쉽게 구현할 수 있었고, MVP 단계에서도 비교적 큰 문제 없이 개발이 진행됐습니다.

하지만 게임을 만들다 보니, 기능은 동작하는데 어딘가 “게임 같다"는 느낌이 부족하다는 생각이 들기 시작했습니다.

  • 과일이 병합될 때 터지는 효과
  • 게임오버 시 쌓인 과일이 하나씩 사라지는 연출
  • 마지막 단계 과일(별사탕)이 반짝거리는 효과
  • 사운드 및 진동을 활용한 피드백

이런 요소들이 들어가야 게임의 완성도를 높일 수 있다고 판단했지만, Matter.js의 기본 렌더러만으로는 이 연출들을 구현하기 어려웠습니다.

그래서 당시 몇 가지 대안을 고민하게 되었는데요.

  • Canvas 위에 별도 이펙트용 Canvas 레이어를 추가
  • DOM/SVG 애니메이션으로 일부 연출을 대체
  • 렌더링 루프를 직접 만들어 Matter.js를 물리 엔진으로만 사용

각각 시도해 볼 수 있는 방식이었지만, 성능이나 구조 복잡도, 이후 유지보수를 생각하면 작은 미니게임에는 과한 선택처럼 느껴졌습니다.

이 과정에서 정리된 요구사항은 다음과 같았습니다.

“렌더링 루프를 직접 만들지 않으면서, 게임에 필요한 기능은 프레임워크에서 제공해주고, 게임 구현에 필요한 물리 연산까지 제공되면 좋겠다.”

그때 떠올랐던 엔진이 Phaser.js였습니다. 예전에 개인적으로 게임 엔진의 라이프 사이클을 공부하면서 Phaser를 알게 되었는데, Phaser가 장면 관리 및 이벤트 연출 시스템을 제공하면서도 물리 엔진으로 Matter.js를 그대로 사용할 수 있다는 점이 요구사항을 모두 만족하는 선택지였습니다.

물론 전환 과정이 매끄럽지만은 않았습니다. 개발 기간은 한 달 정도로 잡혀 있었고, 이미 일정의 많은 부분을 소진한 상태에서 구조를 바꾸는 일은 꽤 큰 리스크였기 때문입니다.

또 기존 React + Matter.js 구조에 Phaser.js를 더하는 과정에서 장면 구조나 라이프사이클, 상태 동기화를 다시 설계해야 했습니다.

그래도 여러 번 검토하며 적용 범위를 조정해 나갔고, “이 선택이 게임의 완성도를 확실하게 올릴 수 있는가"를 기준으로 조심스럽게 전환을 진행하였고, 결과적으로 Phaser.js 도입은 큰 도움이 되었습니다.

과일이 병합될 때 터지는 효과, 스프라이트 시트 애니메이션을 통해 별사탕이 귀엽게 반짝거리는 효과 등 Matter.js만으로는 어려웠던 게임다운 느낌을 구현할 수 있었습니다.

게임을 만들면서 고민했던 부분들

아래는 구현 과정에서 기억에 남았던 고민을 몇 가지 정리해 보았습니다.

1. 왜 과일 이미지는 모두 “원형"일까?

게임을 구현하면서 가장 먼저 들었던 의문 중 하나가 “왜 대부분의 수박게임은 과일 이미지가 전부 원형으로 되어있을까?“였습니다.

처음에는 단순히 캐주얼하게 보여주려는 디자인적인 선택이라고 생각했는데, 조금 더 찾아보니 물리 엔진의 충돌 판정 방식 때문이라는 걸 알게 되었습니다.

Matter.js에서 픽셀 단위로 정밀 충돌을 계산하려면 이미지의 형태 전체를 매 프레임 분석해야 하는데, 이는 연산 비용이 크게 증가하게 됩니다.

반면 원형 충돌은 반지름 기반의 단순 계산으로 처리되기 때문에 안정적이고 빠르게 동작합니다. 그래서 대부분의 수박게임이 자연스럽게 원형 에셋을 선택하고 있었던 것 같습니다.

방식 정밀도 구현 난이도 성능 추천 상황
Canvas로 알파 추출 후 폴리곤 변환 매우 높음 높음 느릴 수 있음 고정밀이 필요한 경우
반지름 보정한 원형 바디 사용 충분히 괜찮음 쉬움 빠름 일반 게임, 수박게임
SVG 기반 변환 정확 중간 SVG 필요 스프라이트가 벡터일 때

이런 배경 때문에 저희도 성능과 안정성을 우선해 모든 과일 이미지를 원형으로 제작하는 방향을 선택했습니다.

2. 재현 가능한 시드 기반 랜덤 생성 (Mulberry32 PRNG)

초기 구현에는 단순히 Math.random()을 사용해 다음 과일을 생성하고 있었지만, 이렇게 하면 게임 난이도 조절이 어렵고 플레이 패턴이 매번 달라져 밸런스 테스트가 힘들었습니다. 무엇보다 정상적인 플레이인지 검증하기도 어려운 구조였습니다.

이 문제를 해결하기 위해 “랜덤이지만 재현 가능한 방식은 없을까?“를 고민했고, 그 과정에서 시드 기반으로 동작하는 PRNG(Pseudo-Random Number Generator) 개념을 처음 접하게 되었습니다. 관련 자료를 찾아보던 중 구조가 단순하고 가벼운 Mulberry32가 눈에 띄었고, 현재 게임 구조와 잘 맞다고 판단해 이를 적용하게 되었습니다.

Mulberry32로 전환하면서 동일한 시드 값 사용시 동일한 순서로 값을 생성하는 것을 보장할 수 있었고, 디버깅이나 테스트 과정에서도 훨씬 안정적인 구조를 만들 수 있었습니다.

// Source: https://github.com/cprosche/mulberry32
function mulberry32(a: number) {
  return function () {
    let t = (a += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const rand = mulberry32(1); // seed: 1
console.log(rand());
// 동일한 생성 순서를 보장
// output: 0.6270739405881613 -> 0.002735721180215478 -> 0.5274470399599522 ...

3. Next.js + Phaser.js 환경에서 만난 SSR 문제

사소한 부분이긴 하지만, Matter.js에서 Phaser.js로 전환하는 과정에서 SSR 에러가 발생했습니다.

확인해 보니 Phaser.js 내부 모듈이 초기화되는 과정에서 브라우저 환경에서만 존재하는 window.navigator.userAgent 객체를 참조하고 있었고, Next.js의 SSR 단계에서는 해당 객체가 없기 때문에 에러가 발생한 것이었습니다.

error

이 문제는 Phaser.js를 사용하고 있는 컴포넌트를 Next.js의 dynamic 함수를 사용하여 브라우저 환경에서만 컴포넌트를 로드하도록 분리해 해결했습니다.

// game 컴포넌트 내부에서 import Phaser from 'phaser' 를 사용.
const Game = dynamic(() => import('@/components/game'), {
  ssr: false,
});

4. 캔버스 DPR(Device Pixel Ratio) 처리 문제

Matter.js를 사용할 때까지만 해도 큰 문제가 없었는데, Phaser.js로 전환한 뒤 과일 이미지가 고해상도 기기에서 흐릿하게 보이는 현상이 발견되었습니다. 기기마다 DPR(Device Pixel Ratio)이 다르다는 것은 알고 있었지만, 왜 두 라이브러리에서 결과가 다르게 나오는지 원인을 파악하기 시작했습니다.

확인해보니 두 엔진의 DPR 처리 방식 차이가 문제의 원인이었습니다.

Matter.js는 DPR 값을 기준으로 캔버스의 실제 렌더링 크기를 자동으로 보정해 주는 반면, Phaser.js는 별도 설정이 없으면 논리적 캔버스 크기(400×600)를 그대로 사용해 렌더링했습니다. 그 결과, DPR이 높은 디바이스에서는 픽셀이 뭉개져 보이는 현상이 발생했습니다.

이 문제를 해결하기 위해 Phaser.js 초기 설정에서 window.devicePixelRatio 값을 직접 사용해 캔버스를 설정했습니다. 예를 들어 DPR이 3인 경우에는 1200×1800처럼 물리 캔버스를 더 크게 생성하고, 내부 오브젝트의 크기와 위치도 해당 배율에 맞춰 조정해 해결했습니다.

5. 웹의 느낌 덜어내기

프로젝트 달성을 위해 꼭 필요한 기능들은 아니었지만, 개인적으로 이번 게임을 만들면서 웹의 느낌은 최대한 덜어내고 사용자가 실제로 ‘게임을 하고 있다’라는 느낌을 받을 수 있도록 신경을 썼던 것 같습니다.

아래는 그 과정에서 챙겨봤던 작은 디테일들입니다.

1. iOS 뒤로가기 제스처 방지: iOS는 화면 왼쪽 모서리에서 스와이프하면 뒤로가기가 실행되는데, 과일을 드래그하는 과정에서 이 제스처가 의도치 않게 발동될 가능성이 있다고 판단했습니다.

게임 플레이 중 갑자기 페이지가 닫히는 것은 사용자 경험 상 큰 문제기 때문에, 이를 방지하기 위해 모서리 영역에서 발생하는 터치 제스처를 캡처해 뒤로가기가 실행되지 않도록 처리했습니다.

2. iOS 상하 스크롤 바운스 제거: iOS 웹뷰에서는 화면 가장자리에서 스크롤 할 때 자연스럽게 바운스가 발생하는데, 이런 동작은 게임의 몰입감을 떨어뜨릴 수 있어 overscroll-behavior-y: none을 적용해 상하 스크롤 바운스가 발생하지 않도록 수정했습니다.

html,
body {
  overscroll-behavior-y: none;
}

3. 진동 피드백 설계: BGM과 효과음을 사용할 수 없는 상황에서 피드백의 대부분은 진동에 의존해야 했습니다. 처음에는 모든 진동의 시간을 100ms로 통일했지만 직접 플레이해 보니 꽤 이질감이 느껴졌습니다.

이후 Android의 Material Design 햅틱 가이드를 참고해 행동별로 진동 시간을 구분했고, 그 결과 실제 플레이 피드백도 훨씬 자연스러워졌습니다.

  • 버튼 클릭: 10ms
  • 스위치 토글: 30ms
  • 과일 병합 및 터질 때: 20ms
  • 게임오버: 500ms

플레이 도중 갑자기 게임오버 처리되는 문제

서비스 오픈 이후, “게임을 플레이 중인데 갑자기 게임오버가 된다"라는 VOC가 계속 들어왔습니다. 처음에는 일부 상황에서만 발생하는 문제라고 생각했지만, 확인해 보니 게임오버 판정 로직 자체에 구조적인 문제가 있었습니다.

원인을 따라가 보니 게임오버 판정은 크게 두 가지 흐름에 의존하고 있었고, 이 둘이 맞물리면서 예기치 않은 게임오버가 발생하고 있었습니다.

문제 원인 1: 과일 생성 직후 타이머 기반 판정

게임 구조상 미리보기 과일은 항상 게임오버 기준선 위에서 생성된 뒤 아래로 떨어집니다. 그래서 생성 직후 바로 게임오버 처리가 되지 않도록 딜레이 타이머를 두고 있었습니다.

fruit.setTriggableAfterDelay(1000); // 일정 시간이 지나면 게임오버 판정 대상이 됨

또 과일을 떨어뜨릴 때마다 전체적인 게임오버 판정도 일정 시간 뒤에 수행되는 구조였습니다.

resetGameOverCheckTimer() {
  this.gameOverTimer = this.time.delayedCall(1200, () => {
    this.checkGameOver();
  });
}

하지만 타이머는 기기의 성능, 프레임 드랍, 백그라운드 복귀 등 외부 환경을 그대로 받기 때문에, 다음과 같은 문제가 발생했습니다.

  • 과일이 아직 떨어지는 중인데 타이머 타이밍이 맞아버리면 그대로 게임오버
  • 타이머가 과일의 물리 상태를 고려하지 않음
  • 실제 플레이 상황과 정확히 맞지 않는 시점에 판정이 실행됨

타이머 시간을 3,000ms로 늘려보기도 했지만, 이는 근본적인 문제 해결이라기보단 단순히 지연만 늘리는 임시 조치에 불과했습니다.

문제 원인 2: 물리 상태를 고려하지 않은 y 좌표 기반 판정

게임오버 판정은 모든 과일의 y 좌표를 기준선과 비교하는 단순한 방식이었습니다.

const isOverTopLine = this.matter.world.getAllBodies().some((body) => {
  if (body.isStatic || !body.gameObject) return false;
  return body.position.y <= this.gameOverLineY;
});

문제는 이 방식이 아래와 같은 정상적인 물리 현상까지 게임오버로 처리하고 있었다는 점입니다.

  • 과일이 떨어지는 중에 기준선을 순간적으로 스치는 경우
  • 다른 과일 위에 안착하기 전 덜컹거리며 흔들리는 순간
  • 물리 엔진 특성상 발생하는 미세한 좌표 튐

즉, 물리적으로 전혀 문제없는 상황에서도 “y 좌표가 기준선을 넘었다"라는 하나의 조건만으로 게임오버가 발생하고 있었습니다.

문제 해결: 과일 상태 기반 구조로 변경

이 문제를 해결하기 위해 기존의 “타이머 + y 좌표” 기반 구조에서 벗어나, 과일의 **현재 상태(state)**를 기준으로 판정하는 방식으로 변경했습니다.

1. 과일 상태 정의: 과일은 dropping / settled 두 가지 상태를 갖게 되며, 새로 생성된 과일은 항상 dropping 상태로 시작합니다.

type FruitState = 'dropping' | 'settled';

class FruitObject extends Phaser.Physics.Matter.Image {
  fruitState: FruitState = 'dropping';

  setFruitState(state: FruitState) {
    this.fruitState = state;
  }

  isSettled() {
    return this.fruitState === 'settled';
  }
}

2. 충돌 이벤트에서 안착 상태로 전환: 충돌 이벤트를 통해 바닥이나 다른 과일에 안착했을 때 settled로 전환합니다.

private handleCollisionStart = (event: Phaser.Physics.Matter.Events.CollisionStartEvent) => {
  for (const pair of event.pairs) {
    const { bodyA, bodyB } = pair;

    const fruitA = bodyA.gameObject instanceof FruitObject && !bodyA.isStatic ? bodyA.gameObject : null;
    const fruitB = bodyB.gameObject instanceof FruitObject && !bodyB.isStatic ? bodyB.gameObject : null;

    // 과일이 바닥 또는 다른 과일과 부딪힌 경우 → 안착 상태로 전환
    if (fruitA && !fruitA.isSettled()) {
      if (bodyB.label === 'Ground' || fruitB) {
        fruitA.setFruitState('settled');
      }
    }
    if (fruitB && !fruitB.isSettled()) {
      if (bodyA.label === 'Ground' || fruitA) {
        fruitB.setFruitState('settled');
      }
    }
    // (아래는 과일 병합 로직 등 다른 처리들…)
  }
};

3. settled 과일만 게임오버 판정에 포함: 기존에는 모든 과일의 y 좌표를 기준선과 비교했지만, 상태 기반으로 변경한 뒤에는 settled 상태인 과일만 판정에 포함하도록 수정했습니다.

// 현재 게임오버 라인 위에 settled 상태의 과일이 있는지 체크
private hasSettledFruitOverLine(): boolean {
	return Array.from(this.activeFruits).some((fruit) => {
		if (!fruit.isSettled()) return false;
		const fruitTopY = fruit.y - fruit.radius / 2;
		return fruitTopY <= this.gameOverLineY;
	});
}

그리고 이 함수는 Phaser Scene의 update() 루프와 게임오버 유예 타이머에서 같이 사용됩니다.

update() {
  if (!this.isGameActive) return;

  const isOverLine = this.hasSettledFruitOverLine();

  if (isOverLine && !this.gameOverTimer) {
    // 처음 기준선을 넘은 순간 → 유예 타이머 시작
    this.startGameOverTimer();
  } else if (!isOverLine && this.gameOverTimer) {
    // 다시 기준선 아래로 내려가면 → 타이머/연출 모두 취소
    this.gameOverTimer.remove();
    this.gameOverTimer = null;
  }
}

startGameOverTimer 내부에서도 마지막으로 한 번 더 상태를 확인한 뒤 정말로 게임오버로 처리할지 결정합니다.

private startGameOverTimer() {
  if (this.gameOverTimer) return;

  this.gameOverTimer = this.time.delayedCall(this.GAME_OVER_DELAY, () => {
    // 타이머가 끝난 시점에도 여전히 라인 위에 settled 과일이 있을 때만 게임오버
    if (this.hasSettledFruitOverLine()) {
      this.setGameState('GAME_OVER');
    }
    this.gameOverTimer = null;
  });
}

이 구조를 적용한 뒤로는 “잠깐 스쳤다"라는 이유로 갑자기 게임오버가 나는 상황이 사라졌고, 플레이 흐름을 더 정확히 반영한 안정적인 게임오버 판정이 가능해졌습니다.

마치며

이번 포치의 선물가게 게임 개발은 한 달이라는 짧은 기간이었지만, 그 안에서 정말 다양한 경험을 해볼 수 있었던 프로젝트였습니다. 물리 엔진, 연출, 게임 로직처럼 평소 웹 개발에서는 쉽게 접하기 어려운 영역을 다루면서 새로운 관점도 얻을 수 있었고, 서비스 디테일을 더 깊게 들여다보는 계기도 되었습니다.

물론 쉽지 않은 순간들도 있었지만, 문제를 하나씩 해결해 나가고 사용자들이 게임을 재미있게 즐겨주셨다는 피드백을 봤을 때 큰 보람도 느낄 수 있었습니다.

앞으로도 더 나은 사용자 경험을 위해 꾸준히 고민하고 시도해 보겠습니다. 긴 글 읽어주셔서 감사합니다.

아래는 실제 게임 플레이 영상입니다.



comments powered by Disqus