このページでは、Pixel Balanceがブラウザの中でどのように動いているのかを、実際のソースコードに即して解説します。使っている物理エンジンの設定値、同色ブロックが消える判定の仕組み、ゲームオーバーの条件など、すべて現在公開中のバージョンの実装そのままです。ゲームの挙動に「なぜ?」と思ったことがある方や、ブラウザゲームの作り方に興味がある方に向けた技術ドキュメントです。
最終更新: 2026年8月11日
Pixel BalanceはJavaScript製の2D物理エンジン「Matter.js」を使用しています。剛体シミュレーション、衝突検知、拘束処理を内蔵したライブラリで、ブラウザ上でそのまま動作するため、プラグインもネイティブコードも必要ありません。
重力は次の1行で設定しています。
engine = Engine.create();
engine.world.gravity.y = 1.2;
Matter.jsのデフォルト重力は1.0ですが、本作では1.2とやや強めにしています。理由は待ち時間の短縮です。ブロックを落としてから着地するまでの時間が長いと、1回のプレイのテンポが間延びします。かといって強くしすぎると着地の衝撃で塔が崩れやすくなりすぎるため、両者の折り合いとしてこの値にしています。重力は「上から下」の一方向のみで、ゲーム中に変化することはありません。プレイヤーは常に同じ物理ルールの上で戦略を組み立てられます。
画面に落ちてくるブロックは、すべて横長の長方形1種類です。ただし幅にランダムな揺らぎを持たせているため、プレイ中は大小さまざまなブロックが降ってくるように感じられます。生成コードは次の通りです。
const w = (baseDimension * 0.08) + (Math.random() * baseDimension * 0.05);
const h = baseDimension * 0.045; // 高さは固定
currentBlock = Bodies.rectangle(canvas.width / 2, spawnY, w, h, {
label: 'pixel',
isStatic: true,
render: { fillStyle: color },
friction: 0.8,
restitution: 0.05
});
baseDimensionはmin(画面幅, 画面高さ×1.5)で求める基準寸法で、端末の画面サイズが違っても相対的なゲームバランスが変わらないようにするための仕組みです。幅は基準寸法の8%〜13%の間でランダム、高さは4.5%固定。つまりアスペクト比がおよそ1.8:1〜2.9:1の間で変化します。「幅の広いブロックが来たら土台に、細いブロックが来たら隙間埋めに」という判断が生まれるのは、この揺らぎのおかげです。
色は次の7色のパステルカラーからランダムに選ばれ、画面上部に「次のブロックの色」がプレビュー表示されます。
const COLORS = ['#F48FB1', '#CE93D8', '#90CAF9',
'#A5D6A7', '#FFF59D', '#FFCC80', '#EF9A9A'];
ブロックには摩擦係数0.8・反発係数0.05、土台(地面)には摩擦係数1.0を設定しています。
塔を載せる土台は、画面中央に置かれた静的な長方形です。幅は基準寸法の60%で、画面の端までは届いていません。つまり土台からはみ出した位置に積んだブロックは、支えを失って落下します。
土台の左右の端には、小さなストッパー(幅は基準寸法の1.5%、高さ3%の突起)が付いています。これは最下段のブロックが着地の勢いでそのまま土台の外へ滑り出てしまう事故を防ぐための「縁」です。ストッパーがあるおかげで、土台の端ぎりぎりを攻める配置にも挑戦できます。
画面上部で左右に揺れているブロックは、物理演算で動いているのではなく、isStatic: trueのまま毎フレーム座標を直接書き換えています。
const time = Date.now() * 0.003;
const range = (baseDimension / 2) - (baseDimension * 0.1);
const x = (canvas.width / 2) + Math.sin(time) * range;
Body.setPosition(currentBlock, { x: x, y: spawnY });
サインカーブによる単純な往復運動なので、端に近づくほど遅く、中央付近で最も速く動きます。「端を狙うのは簡単、中央付近のピンポイントは難しい」という操作の緩急は、この式から自然に生まれています。
タップ(クリック)するとBody.setStatic(currentBlock, false)が呼ばれ、その瞬間からブロックは物理エンジンの管理下に入って落下します。700ミリ秒後にスコアが1加算され、次のブロックが出現します。
本作のスコアはきわめてシンプルで、加点はすべて1点ずつです。
高さボーナス、連鎖倍率、コンボといった仕組みはありません。画面に表示されるフローティングテキストが常に「+1」なのはこのためです。倍率システムを入れなかったのは意図的な設計判断で、スコアを「何個積み、何個消したか」の合計そのものにすることで、ランキングの数字がそのままプレイ内容を表すようにしています。100点のプレイヤーは、100回の投下と消去を成功させた人です。
「同じ色のブロックが3個以上くっつくと消える」判定は、次の流れで実装されています。
collisionStartイベント(新しい接触の発生)でフラグを立て、物理更新後のafterUpdateで1回だけ判定関数を実行します。毎フレーム無条件に判定するのではなく「接触に変化があったときだけ」動かすことで、計算負荷を抑えています。World.removeで物理世界から削除されます。削除の直後には、もうひとつ大事な処理があります。
all.forEach(b => { if (!b.isStatic) Matter.Sleeping.set(b, false); });
物理エンジン上で静止状態と見なされていたブロックを全部「起こす」処理です。消えたブロックの上に載っていたブロックは、支えを失ったのだから落下し直す必要があります。この1行がないと、宙に浮いたまま止まっているように見えるブロックが発生することがあります。なお、Matter.jsのSleeping機能(静止した物体を計算から除外する最適化)自体は本作では有効化しておらず、この呼び出しは消去後の再沈降を確実にするためだけに使っています。
ゲームオーバーの条件は「ブロックが画面の下端より100ピクセル下まで落ちたこと」です。描画ループの中で毎フレーム、全ブロックの座標をチェックしています。
if (pos.y > canvas.height + 100 && !body.isStatic && !body.isRemoving) {
gameOver(body);
}
猶予フレームや復帰判定はなく、条件を満たした瞬間に即ゲームオーバーです。画面外100ピクセルというマージンがあるため、塔から弾かれたブロックが画面の外に見えなくなってから一拍おいて終了する、という体感になります。
ゲームオーバー時には、1プレイに1回だけ使える復活が提示されます。復活すると、ゲームオーバーの引き金になったブロックが取り除かれ、isInvincible フラグが約2秒間立ちます。この無敵時間の間に画面外へ落ちたブロックはゲームオーバー判定を通らず World.remove() で削除されるだけになるため、崩れかけた塔が落ち着くまでの猶予として機能します。
プレイ記録として計測している「最大同時のせ個数」と「最大高さ」は、30フレームに1回の頻度で次のように計算しています。
Bounds.overlapsで粗く絞り込み、SAT.collidesで正確に判定)から接触グラフを構築します。単に「存在するブロック数」を数えるのではなく「塔として成立しているブロック数」を数えているのがポイントで、ステッカーの解除条件(同時20個のせ、高さ5ブロックなど)はこの値を参照しています。
描画にはMatter.js付属のレンダラー(Matter.Render)を使わず、requestAnimationFrameで回す自前のCanvas 2D描画を採用しています。物理エンジンからは各ボディの位置と角度だけを受け取り、塗り・枠線・上面のハイライト・消去時の縮小アニメーションなどをすべて自分で描いています。見た目の表現(テーマカラーやステッカー背景など)を物理演算から切り離して自由に差し替えるための構成です。
ランキングはFirebase Firestoreで実装しています。Googleアカウントでログインすると、スコアが日付キー・月キーとともに保存され、「日次」「月間」「全期間」の3種類のランキングに反映されます。ランキングはゲーム画面からいつでも閲覧できます。