30分で作る積み上げゲーム:揺れる台の上に置く
遊びをつくる執筆にClaudeを使用しています。

積み上げゲームの基本は、左右に動くブロックをタイミングよく止めることです。止めた位置が下のブロックとずれていれば、はみ出した分が切り落とされます。ルールは単純ですが、これだけだと「ブロックの動き」という1つのリズムを読むだけのゲームになります。 そこに揺れる台を足すと、読むものが2つになります。ブロックは左右に動き、積んだ塔全体も台ごと揺れています。ボタンを押す瞬間に見るべきなのは、画面上の位置ではなく「いまの塔に対する相対位置」です。この記事の核は、動く台とタイミングで止める判定の組み合わせです。 30分で作れるかという問いには、コード約40行のHTML1枚で足りる、と答えられます。難しいのは台の揺れそのものではなく、判定を「台から見た座標」で行うことです。その考え方を、手を動かしながら確かめていきます。
手順1〜3:全体を動かす
最初の10分で、動くところまで作ります。準備するものは、テキストエディタとブラウザだけです。画像も外部ライブラリも使いません。
- 空のテキストファイルを作り、index.html という名前で保存します。アソバースにZIPで投稿する場合、入口は直下の index.html なので、この名前にしておくと後で迷いません。
- 下のコードをすべて貼り付けて保存します。
- ブラウザで開き、クリックかスペースキーでブロックを落とします。ゲームオーバーになったら、もう一度クリックすると最初からやり直せます。
<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="360" height="520" style="background:#123;display:block;margin:auto"></canvas>
<script>
const g = document.getElementById("c").getContext("2d");
const H = 24, BASE = 460;
let t = 0, stack, cur, dir, score, over;
function reset() {
stack = [{ x: -80, w: 160 }];
cur = { x: -180, w: 160 }; dir = 1; score = 0; over = false;
}
function sway() { return Math.sin(t / 700) * (20 + Math.min(score, 15) * 4); }
function drop() {
if (over) { reset(); return; }
const top = stack[stack.length - 1];
const lx = cur.x - sway();
const l = Math.max(lx, top.x), r = Math.min(lx + cur.w, top.x + top.w);
if (r - l <= 0) { over = true; return; }
stack.push({ x: l, w: r - l });
cur.w = r - l; score++;
}
function frame(now) {
const dt = Math.min(now - t, 50); t = now;
cur.x += dir * dt * (0.15 + score * 0.01);
if ((cur.x < -180 && dir < 0) || (cur.x + cur.w > 180 && dir > 0)) dir = -dir;
const cam = Math.max(0, stack.length - 10) * H;
g.clearRect(0, 0, 360, 520);
g.save(); g.translate(0, cam);
g.fillStyle = "#789"; g.fillRect(180 + sway() - 100, BASE, 200, 10);
stack.forEach((b, i) => { g.fillStyle = `hsl(${i * 30},70%,60%)`; g.fillRect(180 + sway() + b.x, BASE - H * (i + 1), b.w, H); });
if (!over) { g.fillStyle = "#fff"; g.fillRect(180 + cur.x, BASE - H * (stack.length + 1) - 30, cur.w, H); }
g.restore();
g.fillStyle = "#fff"; g.font = "20px sans-serif";
g.fillText(over ? "終了 " + score + " 段(クリックで再開)" : score + " 段", 12, 28);
requestAnimationFrame(frame);
}
addEventListener("pointerdown", drop);
addEventListener("keydown", e => { if (e.code === "Space") drop(); });
reset(); requestAnimationFrame(frame);
</script>動かしたら、まず次の3点を目で確かめます。
- 灰色の台が左右にゆっくり揺れ、積んだブロックが台と一緒に動くか
- 白いブロックが左右に往復しているか
- 少しずれて落とすと、はみ出した分だけ幅が細くなるか
どれかが違う場合は、コードの貼り付け漏れを疑ってください。特に末尾の </script> まで含まれているかを確認します。
判定の中身:台ローカル座標で重なりを測る
このゲームで押さえる点は、drop() の中の1行です。
const lx = cur.x - sway();cur.x は、画面の中心を0としたときの白いブロックの位置です。sway() は、いまの台の中心が画面中心からどれだけずれているかを返します。この差が、台から見たブロックの位置(台ローカル座標)になります。積んだブロックはすべて台ローカル座標で記録されているので、比較の条件がそろいます。
数字で確かめてみます。台が右に30ずれた瞬間、白いブロックの左端が画面中心から40の位置にあるとします。ローカル座標は 40 − 30 = 10 です。下のブロックが -80〜80 の範囲を占めていて、白いブロックの幅が160なら、白いブロックは 10〜170 の範囲です。重なるのは 10〜80 の70ぶんなので、新しいブロックの幅は70になります。
ここでもし画面座標のまま比較すると、台が揺れるたびに積んだブロックの位置が変わってしまい、判定が成り立ちません。「動くものの上に置く」ゲームでは、基準にする座標系を1つに決めることが先です。
プレイ感覚の面では、台の揺れ方が sin なので端で一瞬ゆっくりになります。台が折り返す付近では塔がほとんど止まって見えます。ブロック側も端で向きを変えるため、両方の折り返しが近いタイミングで重なる瞬間が狙い目になります。この「待つ価値のある瞬間」が生まれることが、揺れる台を足した効果です。ただし、実際にどの程度遊びやすいかは感覚の問題なので、次の手順で自分で確かめます。
判定が正しく働いているか不安な場合は、const lx の次の行に console.log(lx, top.x, top.w); を足し、ブラウザの開発者ツールのコンソールで値を見ます。ローカル座標が台の揺れに左右されず、ブロックの位置だけで変わるなら正しく動いています。
手順4〜6:揺れとリズムを調整して記録する
ここからの15分は、遊びやすさを調整します。感覚だけで直すと堂々巡りになるので、記録を取りながら進めます。
- 調整するつまみを確認します。いまのコードで効果が大きいのは次の4つです。
- 揺れの周期、場所:t / 700 の700、大きくすると:揺れがゆっくりになる
- 揺れの初期幅、場所:20 + の20、大きくすると:最初から塔が大きく動く
- 揺れの増え方、場所:* 4 の4、大きくすると:段が増えるほど揺れが強まる
- ブロックの速さ、場所:0.15 と 0.01、大きくすると:白いブロックが速くなる
- 1回の変更で動かすつまみは1つだけにします。2つ同時に変えると、どちらが効いたのか分からなくなります。変更したら10回続けて遊び、その都度「到達した段数」をメモします。メモは紙でもスプレッドシートでも構いません。
- 10回ぶんの段数から、最大値と中央値(小さい順に並べた真ん中の値)を出します。平均だけだと、まぐれの1回に引っ張られるためです。
結果の読み方は、次のような目安で考えます。これは判断の足がかりで、決まった正解ではありません。
- 中央値が2〜3段で、ほとんどの回が序盤で終わる場合は、難しすぎる可能性があります。揺れの初期幅を小さくするか、ブロックの速さを下げます。
- 中央値は高いのに最大値とあまり差がない場合は、緊張感が足りない可能性があります。揺れの増え方を大きくします。
- 最大値と中央値の差が大きい場合は、運の要素が強い状態です。揺れの周期と、ブロックの往復の周期が偶然そろうかどうかで結果が決まっているかもしれません。周期を少しずらしてみます。
自分以外の人に遊んでもらえるなら、操作の説明をせずに渡し、最初の3回で何が起きるかを見てください。台が揺れていることに気づかず、ブロックだけを見て押している人がいれば、台の色を目立たせるなど視覚の工夫が必要です。
時間が余ったら、「ぴったりボーナス」を足します。下のブロックとほぼ同じ位置に置けたとき、幅を削らずに積む仕組みです。const lx の直後に次の1行を入れます。
if (Math.abs(lx - top.x) < 4) { stack.push({ x: top.x, w: top.w }); score++; return; }許容の4を大きくすると甘い判定になり、小さくすると厳しくなります。ここも10回遊んで記録し、段数の中央値がどう動くかで決めます。
起こりやすいつまずき
- 押した瞬間の位置が、見た目とずれる。 sway() は t から計算されるので、フレームごとの描画と判定が同じ時刻を参照しています。判定のために別の時刻を使うと、見た目とずれます。
- タブを切り替えて戻ると、ブロックが飛ぶ。 経過時間が大きくなるためです。このコードでは Math.min(now - t, 50) で1フレームの進み幅を制限しています。この行を消さないようにします。
- キーボードが反応しない。 ページ内を一度クリックしてフォーカスを移します。クリックでも落とせるので、操作自体はできます。
- 塔が高くなると画面外に出る。 cam で10段を超えたぶんだけ画面を下げています。この値を大きくすると、見える段数が減ります。
- 揺れが大きすぎて台が画面外に出る。 Math.min(score, 15) が上限を決めています。この上限と * 4 を一緒に大きくしないでください。
- 投稿してから音や画像を足したくなる。 素材を使う場合は相対パスで指定し、直下に index.html を置いたZIPにします。サーバー用のコードやサービスワーカーは投稿の対象外です。
次の行動
まず上のコードを貼って、10回遊び、到達段数の中央値をメモしてください。次に、揺れの周期(700)を400と1200に変えて同じ記録を取り、自分にとって気持ちのよい揺れ方を決めます。
形になったら、アソバースの投稿画面にHTMLをアップロードし、「入力→表示の確認→公開」の流れで試せます。公開にはメールアドレスの確認が必要です。投稿前に下書き保存をして、表示確認で操作できることを確かめるのがおすすめです。

