KAPLAYで学ぶブラウザゲームの作り方 バイブコーディング入門
KAPLAYとは何か、まず整理しておく
KAPLAY(kaplayjs)は、JavaScriptで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); // 1秒あたり200px右に移動
});
onKeyDown("left", () => {
player.move(-200, 0);
});
onKeyPress("space", () => {
if (player.isGrounded()) {
player.jump();
}
});
move()はフレーム時間(デルタタイム)を自動で反映し、1秒あたりの速度基準でオブジェクトを移動させる。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アシスタントに具体的にリクエストしやすい単位に分割できる。「障害物の種類をランダムで2種類追加して」「ジャンプ中は二段ジャンプを禁止して」「コインを取ったらスコアにボーナスを付けて」といったリクエストは、既に存在するコンポーネントの組み合わせパターンの上での局所的な修正で済むため、大きなリファクタリングなしでもAIが正確な差分を作りやすい。
ただし、いくつかは人が直接手を入れたほうがいい。まず、area()の当たり判定サイズやbody()のジャンプ力といった数値は、実際にプレイしながらチューニングしないと手応えが出ない。AIが出した初期値をそのまま使うのではなく、何度かプレイして数値を調整する過程が欠かせない。次に、アセット(スプライト、サウンド)の読み込みパスやファイルの有無はAIが確認できない領域なので、自分で検証する必要がある。loadSpriteに渡したパスが実際にpublic/配下にあるか、拡張子は合っているかは、ブラウザコンソールの404エラーで確かめるのが一番早い。
3つ目に、シーンが増えてくると、グローバル関数方式(kaplay()のデフォルトオプション)よりもkaplay({ global: false })でネームスペースを明示したほうが、コード補完やAIのコード生成精度の面で有利になる。どの関数がKAPLAYのもので、どれがプロジェクト独自のユーティリティ関数なのかを名前の衝突なく区別できるからだ。
KAPLAYは重量級のゲームエンジンに比べて参入障壁がはるかに低く、数十行のコードでプレイ可能な結果がすぐに出てくる点で、AIと一緒に素早くアイデアを検証する作業に向いた選択肢だ。上のサンプルをベースに、スプライトアニメーション(sprite()のアニメーションフレーム設定)、サウンド(play())、ローカルストレージを使ったハイスコア記録くらいを追加するだけで、共有できるレベルのミニゲームを1日で仕上げられる。
サウンドとスプライトアニメーションを追加する
ミニゲームに生き生きとした感じを加える一番手早い方法は、効果音とキャラクターアニメーションだ。サウンドは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が生成したコードでもよく出てくるので、コンソールにエラーが出ていないのに衝突だけ効かないなら、まずタグの文字列を疑うといい。