카플레이(KAPLAY)로 배우는 바이브 코딩 브라우저 게임 제작
KAPLAY가 뭔지부터 정리하자
KAPLAY(kaplayjs)는 자바스크립트로 2D 브라우저 게임을 빠르게 만들 수 있는 게임 라이브러리다. 이름이 낯설다면 전신인 Kaboom.js를 들어봤을 가능성이 높다. Kaboom.js는 원래 Replit 팀이 만들어서 유지보수하던 프로젝트였는데, 개발이 멈추면서 커뮤니티가 포크해 이름을 KAPLAY로 바꾸고 계속 이어가고 있는 프로젝트다. API 철학과 컴포넌트 기반 구조는 Kaboom.js를 그대로 이어받았지만, 이후 TypeScript 지원 강화, API 정리, 버그 수정이 계속 들어가고 있어서 지금 새로 시작한다면 Kaboom.js가 아니라 KAPLAY 쪽을 쓰는 게 맞다.
KAPLAY의 핵심 특징은 "함수형 + 컴포넌트 조합"이다. Unity나 Godot처럼 씬 에디터가 있는 무거운 엔진이 아니라, 코드 몇 줄로 게임 오브젝트를 선언하고 컴포넌트를 조합해서 행동을 붙이는 방식이다. 학습 곡선이 낮아서 "AI 어시스턴트와 대화하면서 빠르게 프로토타입을 뽑아보는" 바이브 코딩 워크플로우에 특히 잘 맞는다. 프레임워크 개념을 길게 설명할 필요 없이 "이런 게임 오브젝트를 만들고 싶다"는 의도만 명확히 전달하면 코드 생성 결과의 정합성이 꽤 높게 나온다.
프로젝트 시작하기
가장 빠른 방법은 공식 스캐폴딩 도구를 쓰는 것이다.
npm create kaplay@latest my-game
cd my-game
npm install
npm run dev
이러면 Vite 기반 개발 서버가 localhost:5173에서 뜨고, HMR이 되는 개발 환경이 바로 준비된다. 번들러 없이 그냥 브라우저에서 바로 실험해보고 싶다면 CDN으로도 된다.
<script type="module">
import kaplay from "https://unpkg.com/kaplay@3001.0.19/dist/kaplay.mjs";
kaplay();
</script>
기존 프로젝트에 직접 설치하려면 npm 패키지명은 kaplay다.
npm install kaplay
kaplay() 초기화와 전역 함수
kaplay()를 호출하면 이후 코드에서 쓸 add, sprite, pos, onKeyDown 같은 함수들이 전역 스코프에 노출된다. 이게 기본 동작이라 별도 import 없이 바로 함수를 쓸 수 있다.
import kaplay from "kaplay";
kaplay();
전역 오염이 싫다면 kaplay({ global: false })로 끄고 반환값을 통해 네임스페이스로 접근하는 방식도 가능하다.
import kaplay from "kaplay";
const k = kaplay({ global: false });
k.loadSprite("bean", "/sprites/bean.png");
k.add([k.sprite("bean"), k.pos(100, 100)]);
초기화 옵션으로 캔버스 크기, 배경색, 픽셀아트 스케일링 등을 지정할 수 있다.
kaplay({
width: 640,
height: 480,
background: [20, 20, 30],
scale: 1,
crisp: true, // 픽셀아트를 흐릿하지 않게
});
스프라이트 로딩과 게임 오브젝트
에셋은 loadSprite로 미리 로드한다. KAPLAY는 내장 테스트 스프라이트인 "bean" 캐릭터를 기본 제공해서 loadBean() 한 줄로 별도 이미지 파일 없이 바로 실험할 수 있다.
loadSprite("bean", "/sprites/bean.png");
// 또는 내장 캐릭터 사용
loadBean();
게임 오브젝트는 add()에 컴포넌트 배열을 넘겨서 만든다. 컴포넌트는 위치, 렌더링 방식, 물리 속성 같은 행동 단위를 조합하는 방식이다.
const player = add([
sprite("bean"),
pos(100, 100),
area(), // 충돌 판정 영역 부여
body(), // 중력과 물리 시뮬레이션 부여
"player", // 태그: 나중에 이 태그로 오브젝트를 그룹 조회 가능
]);
area()는 충돌 검사가 가능한 히트박스를 부여하고, body()는 중력을 적용받아 바닥에 떨어지거나 점프할 수 있게 만든다. 문자열("player")을 배열에 그대로 넣으면 태그로 등록되어, 나중에 get("player")나 onCollide("player", ...)처럼 태그 기반으로 오브젝트를 조회하거나 충돌을 감지할 수 있다.
입력 처리
키보드 입력은 onKeyDown(누르고 있는 동안 매 프레임 호출)과 onKeyPress(누르는 순간 한 번만 호출)로 나뉜다.
onKeyDown("right", () => {
player.move(200, 0); // 초당 200px 오른쪽 이동
});
onKeyDown("left", () => {
player.move(-200, 0);
});
onKeyPress("space", () => {
if (player.isGrounded()) {
player.jump();
}
});
move()는 프레임 시간(delta time)을 자동으로 반영해서 초당 속도 기준으로 오브젝트를 이동시킨다. jump()는 body() 컴포넌트가 제공하는 메서드로, 중력이 적용된 오브젝트에 수직 속도를 부여해 점프 동작을 구현한다.
씬과 충돌 처리
여러 화면(타이틀, 게임플레이, 게임오버)을 구성할 때는 scene()으로 나누고 go()로 전환한다.
scene("game", () => {
const player = add([
sprite("bean"),
pos(80, 40),
area(),
body(),
"player",
]);
add([
rect(width(), 40),
pos(0, height() - 40),
area(),
body({ isStatic: true }), // 움직이지 않는 바닥
color(80, 200, 120),
]);
const coin = add([
circle(12),
pos(300, height() - 80),
area(),
color(255, 220, 50),
"coin",
]);
player.onCollide("coin", (c) => {
destroy(c);
addKaboom(player.pos); // 내장 폭발 이펙트
});
});
scene("gameover", () => {
add([text("Game Over", { size: 48 }), pos(center()), anchor("center")]);
onKeyPress("space", () => go("game"));
});
go("game");
onCollide는 area() 컴포넌트를 가진 오브젝트끼리 겹칠 때 호출된다. 첫 번째 인자로 넘긴 태그를 가진 오브젝트와 충돌했을 때만 콜백이 실행되므로, 코인·적·장애물처럼 종류별로 따로 반응을 정의하기 편하다. body({ isStatic: true })는 물리 시뮬레이션에는 참여하지만 중력이나 외력에 의해 스스로 움직이지 않는 오브젝트(바닥, 벽)를 만들 때 쓴다.
완전한 예제: 장애물 피하기 게임
지금까지 나온 요소를 합쳐서 실제로 돌아가는 작은 게임을 만들어보면 이렇다. 스페이스바로 점프해서 다가오는 장애물을 피하는 러너 게임이다.
import kaplay from "kaplay";
kaplay({
width: 640,
height: 360,
background: [20, 24, 40],
});
loadBean();
scene("game", () => {
const GROUND_Y = height() - 60;
const player = add([
sprite("bean"),
pos(80, GROUND_Y),
area(),
body(),
anchor("bot"),
"player",
]);
add([
rect(width(), 60),
pos(0, GROUND_Y),
area(),
body({ isStatic: true }),
color(60, 60, 80),
]);
onKeyPress("space", () => {
if (player.isGrounded()) {
player.jump(600);
}
});
// 점수 표시
let score = 0;
const scoreLabel = add([
text(`Score: ${score}`, { size: 24 }),
pos(16, 16),
]);
// 일정 간격으로 장애물 스폰
loop(1.2, () => {
add([
rect(30, 50),
pos(width(), GROUND_Y),
anchor("bot"),
area(),
move(LEFT, 240),
offscreen({ destroy: true }),
color(220, 90, 90),
"obstacle",
]);
});
player.onCollide("obstacle", () => {
go("gameover", score);
});
onUpdate(() => {
score += dt();
scoreLabel.text = `Score: ${Math.floor(score)}`;
});
});
scene("gameover", (finalScore) => {
add([
text(`Game Over\nScore: ${Math.floor(finalScore)}\nPress space to retry`, {
size: 28,
align: "center",
}),
pos(center()),
anchor("center"),
]);
onKeyPress("space", () => go("game"));
});
go("game");
여기서 몇 가지 주목할 만한 함수가 나온다. loop(초, 콜백)은 지정한 시간 간격마다 콜백을 반복 실행하는 타이머 함수로, 장애물을 일정 주기로 생성하는 데 쓴다. move(LEFT, 240)은 컴포넌트로, 오브젝트에 붙이면 지정한 방향으로 매 프레임 자동 이동한다. offscreen({ destroy: true })는 오브젝트가 화면 밖으로 나가면 자동으로 제거해주는 컴포넌트라 장애물이 무한히 쌓여 메모리를 잡아먹는 걸 막아준다. onUpdate는 매 프레임 실행되는 훅으로, 점수 누적처럼 지속적인 상태 갱신에 쓴다. dt()는 지난 프레임과의 시간 간격(초)을 반환해서 프레임레이트에 관계없이 일정한 속도로 값을 증가시킬 수 있게 해준다.
바이브 코딩으로 확장하기
이 정도 뼈대가 잡히면 이후 확장은 AI 어시스턴트에게 구체적으로 요청하기 좋은 단위로 쪼개진다. "장애물 종류를 랜덤으로 두 가지 추가해줘", "점프 중에는 이중 점프를 막아줘", "코인을 먹으면 점수에 보너스를 줘" 같은 요청은 이미 존재하는 컴포넌트 조합 패턴 위에서 국소적인 수정으로 끝나기 때문에, 큰 리팩터링 없이도 AI가 정확한 diff를 만들어내기 쉽다.
다만 몇 가지는 사람이 직접 챙기는 게 낫다. 첫째, area()의 충돌 판정 크기나 body()의 점프 힘 같은 수치는 실제로 플레이해보면서 튜닝해야 손맛이 산다. AI가 뽑아준 초기값을 그대로 쓰기보다 몇 번 플레이해보고 숫자를 조정하는 과정이 꼭 필요하다. 둘째, 에셋(스프라이트, 사운드) 로딩 경로나 파일 존재 여부는 AI가 확인할 수 없는 영역이라 직접 검증해야 한다. loadSprite에 넘긴 경로가 실제로 public/ 아래 있는지, 확장자가 맞는지는 브라우저 콘솔의 404 에러로 바로 확인하는 게 빠르다.
셋째, 씬이 늘어나기 시작하면 전역 함수 방식(kaplay() 기본 옵션)보다 kaplay({ global: false })로 네임스페이스를 명시하는 편이 코드 자동완성이나 AI의 코드 생성 정확도 면에서 유리해진다. 어떤 함수가 KAPLAY 것이고 어떤 게 프로젝트 자체 유틸 함수인지 이름 충돌 없이 구분되기 때문이다.
KAPLAY는 무거운 게임 엔진 대비 진입 장벽이 훨씬 낮고, 코드 몇십 줄로 플레이 가능한 결과물이 바로 나온다는 점에서 AI와 함께 빠르게 아이디어를 검증해보는 작업에 잘 맞는 선택지다. 위 예제를 기반으로 스프라이트 애니메이션(sprite()의 애니메이션 프레임 설정), 사운드(play()), 로컬 저장소를 이용한 최고 점수 기록 정도만 추가해도 공유 가능한 수준의 미니 게임을 하루 안에 완성할 수 있다.
사운드와 스프라이트 애니메이션 추가하기
미니 게임에 생동감을 더하는 가장 빠른 방법은 효과음과 캐릭터 애니메이션이다. 사운드는 loadSound로 로드하고 play()로 재생한다.
loadSound("jump", "/sounds/jump.mp3");
loadSound("coin", "/sounds/coin.mp3");
onKeyPress("space", () => {
if (player.isGrounded()) {
player.jump(600);
play("jump", { volume: 0.6 });
}
});
player.onCollide("coin", (c) => {
destroy(c);
play("coin");
});
스프라이트가 여러 프레임으로 구성된 스프라이트 시트라면 loadSprite에 sliceX, sliceY로 프레임을 분할하고 애니메이션 이름을 등록한다.
loadSprite("player", "/sprites/player.png", {
sliceX: 4,
sliceY: 1,
anims: {
run: { from: 0, to: 3, loop: true, speed: 8 },
idle: { from: 0, to: 0 },
},
});
const player = add([sprite("player", { anim: "idle" }), pos(80, 100), area(), body()]);
onKeyDown("right", () => {
player.move(200, 0);
if (player.curAnim() !== "run") player.play("run");
});
onKeyRelease("right", () => {
player.play("idle");
});
sliceX와 sliceY는 원본 이미지를 몇 등분으로 잘라 프레임으로 쓸지 지정하는 값이고, anims 객체에 이름별로 프레임 범위(from~to)와 반복 여부, 재생 속도를 정의해두면 player.play("run")처럼 이름으로 애니메이션을 전환할 수 있다.
모바일 터치 입력 대응
브라우저에서 실행되는 게임이니만큼 모바일 터치 입력도 챙겨두면 공유하기 좋다. KAPLAY는 마우스 이벤트와 별개로 터치 이벤트를 제공한다.
onTouchStart((pos, touch) => {
if (player.isGrounded()) {
player.jump(600);
}
});
화면을 좌우로 나눠서 가상 버튼처럼 쓰고 싶다면 터치 좌표(pos)의 x값을 화면 절반과 비교해서 분기하면 된다.
onTouchStart((pos) => {
if (pos.x < width() / 2) {
player.move(-200, 0);
} else {
player.move(200, 0);
}
});
이 정도만 추가해도 데스크톱과 모바일 양쪽에서 큰 위화감 없이 플레이할 수 있는 결과물이 나온다. KAPLAY 자체가 캔버스 기반이라 별도의 반응형 레이아웃 작업 없이도 kaplay({ width, height, stretch: true, letterbox: true }) 같은 옵션으로 화면 비율을 유지한 채 다양한 화면 크기에 대응시킬 수 있다.
이렇게 사운드, 애니메이션, 터치 입력까지 얹으면 프로토타입 수준을 넘어서 실제로 링크 하나로 공유할 수 있는 완성도의 미니 게임이 된다. 바이브 코딩의 장점은 이 확장 단계에서 특히 두드러진다. "달리기 애니메이션을 추가해줘", "점프할 때 효과음을 넣어줘"처럼 이미 구조가 잡힌 코드 위에 기능 단위로 요청하면, AI가 기존 컴포넌트 조합 패턴을 그대로 따라가면서 자연스럽게 통합된 코드를 만들어주는 경우가 많다.
배포하기: 정적 파일로 바로 호스팅
KAPLAY 게임은 브라우저에서 실행되는 순수 프론트엔드 결과물이라, 별도 백엔드 서버 없이 정적 호스팅만으로 배포가 끝난다. npm run build로 빌드하면 Vite가 dist/ 디렉토리에 정적 파일을 뽑아준다.
npm run build
이 산출물을 Cloudflare Pages, Vercel, Netlify, GitHub Pages 어디든 그대로 올리면 된다. 예를 들어 Cloudflare Pages라면 빌드 명령을 npm run build, 출력 디렉토리를 dist로 지정하는 것만으로 배포가 끝난다. 별도 서버 상태나 데이터베이스가 필요 없는 순수 클라이언트 게임이라 배포 파이프라인이 매우 단순하다는 점도 짧은 시간 안에 결과물을 완성하고 공유하기 좋은 이유 중 하나다.
디버깅 팁
게임 오브젝트가 예상대로 움직이지 않을 때 가장 먼저 확인할 건 컴포넌트 순서다. KAPLAY는 컴포넌트 배열의 순서 자체는 크게 상관없지만, area() 없이 onCollide를 걸거나 body() 없이 jump()를 호출하면 조용히 무시되거나 에러가 나는 경우가 많다. 개발 중에는 debug.inspect = true로 디버그 모드를 켜두면 각 오브젝트의 히트박스와 컴포넌트 상태를 화면에 오버레이로 보여줘서 어떤 컴포넌트가 빠졌는지 눈으로 바로 확인할 수 있다.
kaplay({ debug: true });
onKeyPress("f1", () => {
debug.inspect = !debug.inspect;
});
또한 onCollide 콜백이 아예 호출되지 않는다면 두 오브젝트 모두 area() 컴포넌트를 갖고 있는지, 그리고 태그 이름 오타는 없는지부터 확인하는 게 가장 빠른 해결 경로다. 이런 사소한 오타는 AI가 생성한 코드에서도 종종 나오기 때문에, 콘솔에 에러가 없는데 충돌만 작동하지 않는다면 태그 문자열을 먼저 의심하는 게 좋다.