これは技術編です。どんなゲームがあって、どう遊ぶのかを読みやすくまとめた読みもの版は「ミニゲームの屋台が並ぶ「あそび横丁」と、エージェントに足してもらう仕組み」にあります。
あそび横丁は、ブラウザですぐ遊べるミニゲームを集めた静的サイトです。asobi.enludus.com で公開していて、この記事は 20 本目までの時点で書いています(その後も増えています)。ソースは github.com/naki0227/mini-game にあります。
ゲームのコードは、コーディングエージェントが書いています。31 件のコミットにはすべてエージェントが共同作者として入っています。自分が決めているのは、作る題材、エージェントに渡す共通仕様、出す前に通す確認の手順です。数字やコードはすべてリポジトリから取ったものです。
全体の構成
index.html ハブ(ゲーム一覧)
404.html 存在しない URL 用
games/index.html /games/ をトップへ移すだけのページ
games/<id>/index.html 各ゲーム(1 ファイル完結)
games/<id>/ogp.png シェアされたときのカード画像(1200×630)
games/<id>/thumb.png ハブに並べるサムネイル(640×480)
.dev/README.md 1 本足す手順
.dev/spec.md 各ゲームの共通仕様(エージェントに渡す)
.dev/ideas.md 作るゲームの候補リスト
.dev/compose.mjs OGP 画像とサムネイルを撮る Playwright スクリプトビルドの工程はありません。package.json もなく、ローカルでは python3 -m http.server 8000 で開くだけです。
ゲームは 1 本につき index.html が 1 つで、HTML・CSS・JavaScript がすべてその中に入っています。画像ファイルは使わず、絵は Canvas か CSS、インライン SVG、three.js で描いています。外から読むのは、物理エンジンや 3D のライブラリと Google Fonts だけです。
1 ファイル完結にしているのは、エージェントに渡す単位をはっきりさせるためです。共通仕様には「games/
共通の部品
ファイルを分けていないので、共有ライブラリはありません。代わりに、共通仕様で「同じ形で入れる」ものを決めています。
記録の保存。 ハイスコアは localStorage に、キー <id>:v1、値は JSON で保存します。読み書きは必ず try/catch で囲み、消えても動くこと。ハンガーキャッチの読み込みはこうなっています(games/catch/index.html)。
const KEY = 'catch:v1';
const DEF = { ver: 2, best: 0, wins: 0, throws: 0, winThrows: 0, plays: 0, bestChain: 0 };
function load() {
try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); if (d && d.ver === 2) return Object.assign({ ...DEF }, d); } catch {}
return { ...DEF }; // 古い形式(10本勝負のころ)は使わない
}ルールを変えたゲームは、値の中に ver を持たせて、古い形式を読まないようにしています。キー名は変えないので、ハブ側は同じキーを読みつつ d.ver === 2 を確かめます。
結果画面。 スコア、自己ベスト、新記録のバッジ、もう一回、X でシェア、結果をコピー、「← ほかのゲームを見る」をそろえます。結果が出たら画面のどこをタップしても即リトライで、結果表示の直後 0.4 秒だけは誤タップとして無視します。シェア用の URL を作る gameUrl() も、参考実装と同じものを使います。
ハブへ戻るボタン。 8 本目まで出したところで、全ゲームにプレイ中でもハブへ戻れる「← 横丁」ボタンを足しました。仕様には HTML・CSS・JS をそのまま貼ってあり、pointerdown などを stopPropagation して、押してもゲームの入力に伝わらないようにしています。
ほかに、フォントはシリーズ共通で Dela Gothic One と Zen Kaku Gothic New、prefers-reduced-motion のときは画面の揺れを止める、効果音は WebAudio で、ユーザーの操作のあとに鳴らし始める、を決めています。
ライブラリの使い分け
20 本を、使っているものごとに分けるとこうなります。
| 使っているもの | ゲーム |
|---|---|
| なし(Canvas と DOM) | 連打おみくじ、お湯ぴったり、だるまさんがころんだ、ハンコ押し、台所ピンボール、ぴったり 10 秒 |
| matter.js 0.19.0 | にぎり積み、ハンガー投げ、ボトルフリップ |
| three.js 0.160.0 + Rapier 0.12.0 | ハンガーキャッチ、だるま落とし、けん玉 もしかめ、輪投げ |
| three.js 0.160.0 + 自前の物理 | たこ焼き返し、紙飛行機、水切り、靴飛ばし、皿回し、竹とんぼ、雪だるま転がし |
matter.js は cdnjs から <script> で、three.js と Rapier(@dimforge/rapier3d-compat)は jsDelivr から ES モジュールとして読みます。共通仕様のはじめには「外部スクリプトは cdnjs.cloudflare.com の固定バージョンのみ」とありますが、10 月 18 日からの定期追加ぶんには「基本は 3D(three.js@0.160.0 を cdn.jsdelivr.net から ES module)」という追加ルールがあり、こちらが優先されています。どちらもバージョンは固定です。
3D のゲームは、ライブラリを動的 import で読み、失敗したときはスタートボタンに理由を出します(games/catch/index.html)。
const THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
const RAPIER_URL = 'https://cdn.jsdelivr.net/npm/@dimforge/rapier3d-compat@0.12.0/rapier.es.js';
let THREE, RAPIER;
try {
const [t, r] = await Promise.all([import(THREE_URL), import(RAPIER_URL)]);
THREE = t; RAPIER = r.default;
await RAPIER.init();
} catch (e) {
$('startBtn').textContent = '読み込めませんでした';物理の側は three.js に頼らない、という書き方も共通しています。だるま落としやけん玉は、物理の部分を /*PHYS-BEGIN*/ と /*PHYS-END*/ で囲み、頭に「three には頼らない。Rapier の剛体だけ」と書いています。描画と物理が分かれているので、同じ物理を画面なしでボットに何百回も回せます。
matter.js:にぎり積みとボトルフリップ
最初の 1 本のにぎり積みは、共通仕様で「参考実装」に指定している 2 本のうちの 1 本です。お寿司は角を丸めた長方形の剛体で、揺れる箸の振り子の速さをそのまま初速にして落とします。
const omega = () => Math.min(1.7 + score * 0.06, 3.8);
...
Body.set(b, { friction: 0.9, frictionStatic: 1.2, restitution: 0, density: 0.0018, frictionAir: 0.01 });
Body.setVelocity(b, { x: vx, y: Math.max(vy, 0) });積むほど omega(揺れの速さ)が上がるので、難しくなります。1 貫が「積めた」とするのは、速さと回転が小さい状態が 14 フレーム続いたとき(または落としてから 200 フレームたったとき)で、皿より下に落ちた 1 貫が 1 つでもあれば終わりです。
ボトルフリップは、ボトルを中が空洞の壁でつくり、水を「胴の中だけを動ける重り」として入れています。判定の関数は、頭のコメントがそのまま方針です(games/flip/index.html)。
// 物理の結果だけで判定する
function judge(w, dts) {
const b = w.bottle; if (!b || w.done) return;
w.t += dts;
const up = Math.cos(b.angle), sp = Body.getSpeed(b), av = Math.abs(Body.getAngularVelocity(b));
const s = w.stage.surf;
const calm = sp < 0.35 && av < 0.02;
const upright = Math.abs(up) > 0.97;まっすぐ立ち、止まっている状態が台の上で 0.6 秒続いたら成功です。台以外で立ったら「おしい!立ったけど、そこじゃない」、7 秒で決着しなければ「時間切れ」になります。
序盤を易しくする仕組みもここにあります。投げの回転に「狙った台に届くころ 1 回転になる」ための手助けを混ぜていて、その割合は成功の回数で減っていきます。
// 手助けは成功するたびに弱くなり、7回目以降はほぼ自分の腕だけ。判定はあくまで物理の結果
const rawness = n => RAW_FORCE ?? Math.min(0.5, n * 0.07);手助けするのは投げる瞬間の回転だけで、立ったかどうかは物理に任せています。
Rapier:ハンガー、だるま、けん玉、輪投げ
3D で物がぶつかったり引っかかったりするゲームには、Rapier を使っています。
ハンガーキャッチ は、針金ハンガーを細いカプセルの折れ線でつくった剛体です。掛かったかどうかは、カギの巻いた部分の中心と竿の軸の距離で見ます。
// 掛かった判定: 竿の周りをくるくる回っている間や、すべって落ちる途中では成功にしない。
// カギが竿に入ったまま、本体が竿より下にぶら下がり、動きが落ち着いたら成功。
function judgeStep(h, dt) {
const hs = hookState(h.body);
if (hs.inside) { h.inT += dt; h.wasIn = true; } else { h.inT = 0; h.calmT = 0; }
if (hs.inside) {
const b = h.body, v = b.linvel(), w = b.angvel(), t = b.translation();
const below = t.y < BAR.y - 0.04;
const calm = Math.hypot(v.x, v.y, v.z) < 0.35 && Math.hypot(w.x, w.y, w.z) < 2.2;
h.calmT = below && calm ? (h.calmT || 0) + dt : 0;
if (h.inT >= HOLD && h.calmT >= SETTLE) return 'hang';最初は「カギの内側に竿が 0.7 秒入っていたら成功」でした。それだと、竿の周りを回って落ちていく途中でも成功になりうるので、「本体が竿より下にあって、動きが落ち着いている」を条件に足しています。竿に絡んで決着がつかないときのために、8 秒で必ず決める行もあります。
輪投げ の輪は、細いカプセル 16 本を輪の形に並べた 1 つの剛体(複合コライダー)です(games/wanage/index.html)。
const K = PH.K, half = PH.R * Math.sin(Math.PI / K), rm = PH.R * Math.cos(Math.PI / K);
for (let i = 0; i < K; i++) {
const a = (i + 0.5) * 2 * Math.PI / K;
const tan = { x: -Math.sin(a), y: 0, z: Math.cos(a) };
world.createCollider(RAPIER.ColliderDesc.capsule(half, PH.RT).setTranslation(rm * Math.cos(a), 0, rm * Math.sin(a)).setRotation(qFromToO(UPO, tan))
.setFriction(PH.MU_RING).setRestitution(PH.E_RING).setDensity(1), b);
}入ったかどうかは、輪が止まったとき、狙った棒の軸が「輪の面」を輪の内側、かつ棒の頭より下で通っているかで決めます。中心が近いだけでは成功にしません。空中だけは輪のぐらつきに簡単な空気の効果を入れていて、スピンがないとぐらつきが育ち、スピンがあるとジャイロの効果でおさまります。「曲げて引くと入りやすい」はここから来ています。
だるま落とし は、長さの単位を 10cm にして、重力も 10 倍(98.1)にしています。小さな物を本物と同じ速さで動かすためです。木づちも剛体で、手から離れた瞬間の速さと向きで飛び、当たった胴と運動量をやりとりします。抜けたかどうかは、止まったあとの位置と傾きだけで決めます。
// 胴が「だるまの柱」から外れたか: 頭の真下から半径ぶん以上外れた・大きく傾いた・頭より上に出た
function isOut(p) {
const t = pos(p.body), h = pos(g.head.body);
const dxz = Math.hypot(t.x - h.x, t.z - h.z);
return dxz > PH.R * 1.25 || g.tiltOf(p.body) > 0.75 || t.y > h.y + 0.2;
}けん玉 は、けんを手で持っている物として kinematicPositionBased の剛体にし、玉は Rapier のロープ拘束でつないでいます。
world.createImpulseJoint(RAPIER.JointData.rope(PH.ROPE, { x: 0, y: 0, z: 0 }, HOLE), ken, ball, true);単位は cm で、重力は本物の 15〜33% に弱めています。指の操作でも間に合うようにするためで、乗るたびに少しずつ強くなり、テンポが上がります。成功は「玉が指定の皿の輪の内側で、皿との相対速度が 7 以下の状態が 0.15 秒続いたとき」です。

Rapier のゲームはどれも、固定の時間刻み(だるま落としとけん玉は 1/600 秒)で world.step() を回し、CCD のサブステップ数を明示しています(ハンガーキャッチ以外の 3 本はソルバーの反復回数も)。
自前の物理:皿回し、紙飛行機、竹とんぼ、雪だるま
物理エンジンの剛体と衝突では表しにくい題材は、運動の式を直接書いています。
皿回し は、皿を対称なコマとして、向き(クォータニオン)と支点まわりの角運動量 L を持ち、毎ステップ「dL/dt = トルク」を足しています。トルクは、重力と棒の先が動くことによる見かけの力、先端の摩擦が軸を起こす力、空気が首ふりを止める力などです。
// 重力と見かけの力: 重心 rc × (g − a)
const rcx = PH.HC * n[0] + PH.EPS * b[0], rcy = PH.HC * n[1] + PH.EPS * b[1], rcz = PH.HC * n[2] + PH.EPS * b[2];
const gx = -A[0], gy = -PH.G - A[1], gz = -A[2];
let tqx = rcy * gz - rcz * gy, tqy = rcz * gx - rcx * gz, tqz = rcx * gy - rcy * gx;
// 先端の摩擦が軸を棒の向きへ起こす
const s = p.s, sn = s[0] * n[0] + s[1] * n[1] + s[2] * n[2];
tqx += PH.CR * ws * (s[0] - sn * n[0]); tqy += PH.CR * ws * (s[1] - sn * n[1]); tqz += PH.CR * ws * (s[2] - sn * n[2]);回っていればジャイロ効果で倒れずに横へ回り(歳差運動)、遅くなると歳差の円が育って落ちます。コメントによると、皿の重心はほんの少しだけ軸からずらしてあり、乱数で落ちることはありません。
紙飛行機 は、折り方ごとに重さ・翼面積・揚力傾斜・失速角・抗力などを持たせ、迎え角から揚力係数と抗力係数を出しています(aeroCoef)。失速角をこえると揚力が落ち、機首が下がります。風と校庭の上昇気流は投げるたびに種から作り直すので、同じ投げ方でも毎回少し違う飛び方になります。
竹とんぼ は、羽根のねじれ角と流入角から迎え角を出し、揚力を速さの 2 乗に比例させています。上がるほど流入角が大きくなって迎え角が減るので、上昇の速さには限りがあります。雪だるま転がし は、転がった距離と前の雪の深さで体積を増やし、ぶつかる向きの速さが雪玉の強さをこえたら割れる、というモデルです。強さは大きさに比例しないので、大きい玉ほど割れやすくなります。
台所ピンボール だけは少し方向が違います。物理と面のデータを SIM にまとめ、頭に「DOM に触らない・乱数なし・決定論的」と書いています。15° 刻みの回転を Math.sqrt だけで作っていて、コメントには「環境差が出ない」とあります。毎回同じに飛ぶ玉を使う配置パズルなので、端末によって答えが変わらないことが大事だからです。
難しさのカーブとボット
共通仕様には「1 投目・1 回目は誰でもそこそこ成功し、だんだん難しくなるカーブ。ボットや物理シミュレーションで成功率を測って報告する」とあります。
そのために、3D のゲームの多くは、人の操作をまねるボットを持っています。けん玉のボットは、腕前を 5 段階のパラメータで持っています(games/kendama/index.html)。
const SKILLS = {
first: { wait: 0.5, A: 6.5, Asd: 0.35, D: 0.1, Dsd: 0.3, xErr: 2.2, react: 0.24, percep: 1.0, gain: 6, knee: 0.15, kneeSd: 0.3, kneeT: 0.16, kneeTsd: 0.1 },
casual: { wait: 0.4, A: 6.5, Asd: 0.25, D: 0.1, Dsd: 0.2, xErr: 1.7, react: 0.2, percep: 0.8, gain: 7, knee: 0.35, kneeSd: 0.25, kneeT: 0.16, kneeTsd: 0.06 },
...ボットが動かすのは指の目標だけで、玉の位置は少し遅れて、少しぶれて見えています。ポンの強さ、受けるときにひざを曲げるタイミングにもばらつきがあります。人と同じ入り口から操作するので、ボットの成功率がそのまま「人がやったらどのくらいか」の目安になります。
14 本のゲームには、検証用の関数があります(多くは URL に #debug を付けたときだけ有効)。ハンガーキャッチなら、投げる角度と向きの格子で成功マップを出せます。
こうした測定の結果は、コミットメッセージに残っています。
- 靴飛ばし:初めての人でも 5m 以上飛ぶよう、最初の振れ幅を 20° から 40° に。ボットで 5m 未満が 37% から 18% に
- 水切り:ふつうの速さではじいた 1 投目で、およそ 4〜8 回
- ハンガーキャッチ:ど真ん中を狙ったときの成功率が 4〜7 割だったので、ブレを 2 倍にして約 2〜3 割に
続けるほど難しくする方法は、ゲームごとに違います。にぎり積みは箸の揺れが速くなり、だるま落としは続けるほど胴が薄くなり、途中から重い胴とすべる胴が混じります。ハンガーキャッチは 1 本かけるごとに投げる人が 25cm 下がり、けん玉は 20 回で小皿が加わります。
重さ
repo の中のファイルサイズを測りました。ゲームの index.html は 1 本あたり 25〜130KB です。gzip(-9)をかけると 9〜42KB になります。
| ゲーム | index.html | gzip | ogp.png | thumb.png |
|---|---|---|---|---|
| にぎり積み | 24.8KB | 9.1KB | 61KB | 18KB |
| ボトルフリップ | 69.5KB | 23.2KB | 147KB | 78KB |
| ハンガーキャッチ | 71.5KB | 24.5KB | 180KB | 173KB |
| 輪投げ | 96.2KB | 33.2KB | 260KB | 285KB |
| 雪だるま転がし | 111.0KB | 38.7KB | 171KB | 97KB |
| たこ焼き返し | 129.6KB | 41.8KB | 234KB | 275KB |
20 本の index.html を合わせると約 1.43MB、PNG は合わせて約 6.1MB(OGP が 3.5MB、サムネイルが 2.6MB)です。ハブの index.html は 16.2KB(gzip で 6.0KB)です。これはリポジトリの中のサイズで、three.js や Rapier、matter.js、フォントは CDN から読むので、この表には入っていません。
サイズの大半はコードより画像です。サムネイルはハブに 20 枚並びます。ハブでは loading="lazy" と width・height を付けていて、画面に入るまで読まず、読んでもレイアウトがずれないようにしています。
3D の描画では、three.js のシャドウマップをどのゲームでも使っていません。影は「簡易の丸い影」のような描き方で済ませています。共通仕様の「影は控えめ、ポリゴン少なめ」に沿ったものです。描画の解像度も、devicePixelRatio をそのまま使わず、上限を決めています。
W = innerWidth; H = innerHeight; dpr = Math.min(devicePixelRatio || 1, 2);
renderer.setPixelRatio(Math.min(dpr, 1.75)); renderer.setSize(W, H, false);シェア画像とサムネイル
ゲームごとの ogp.png(1200×630)と thumb.png(640×480)は、.dev/compose.mjs が撮っています。
- Playwright の Chromium で、400×760 の画面にゲームを開く
- 渡された操作の列(クリック、押す、動かす、離す、キー、待つ)をそのまま再生して、見栄えのいい瞬間を作る
- その画面を撮り、真ん中あたりを 4:3 に切り抜いてサムネイルにする
- 1200×630 のページを組み、左にタイトルと説明、右に少し傾けたスマホの画面を置いて OGP を撮る

for (const a of JSON.parse(actions)) {
if (a.click) await p.mouse.click(a.click[0], a.click[1]);
if (a.down) { await p.mouse.move(a.down[0], a.down[1]); await p.mouse.down(); }
if (a.move) await p.mouse.move(a.move[0], a.move[1], { steps: a.move[2] || 1 });
if (a.up) await p.mouse.up();
if (a.key) await p.keyboard.press(a.key);
if (a.eval) await p.evaluate(a.eval);
if (a.wait) await p.waitForTimeout(a.wait);
}3D のゲームも撮れるように、ブラウザは --use-gl=angle --use-angle=swiftshader で起動します。GPU のない環境でもソフトウェアで WebGL を描くためです。
操作の列は、ゲームを作ったエージェントが「そのまま再生できる形」で報告することになっています。皿回し・竹とんぼ・雪だるま転がしには、#debug&skip=秒 で最初にその秒数だけ進めておく仕組みもあり、ボットに遊ばせた途中の画面を撮れます。
ハブ
ハブの index.html は、GAMES という配列を 1 つ持っていて、そこからカードを組み立てます。
{
no: 15, id: 'otoshi', title: 'だるま落とし', noren: ['だ', 'る', 'ま', '落', 'とし'],
desc: '木づちで胴を1段ずつスコーン。頭を落とさず何段ぬける?',
href: '/games/otoshi/', thumb: '/games/otoshi/thumb.png',
storageKey: 'otoshi:v1', best: d => d.plays ? `<b>${d.best}</b> 段・${d.plays}回` : null,
},noren はカードの上ののれんに 1 枠ずつ入る文字です。ハブとゲームは同じオリジンなので、ハブは各ゲームの localStorage を読んで、best 関数で「あなたの最高」を出します。読めなかったら「まだ遊んでいません」です。

function readBest(g) {
try { const d = JSON.parse(localStorage.getItem(g.storageKey) || 'null'); return d ? g.best(d) : null; } catch { return null; }
}この best 関数も、ゲームを作ったエージェントが報告に書くことになっています。
公開
Cloudflare Pages に、GitHub の main ブランチをつないでいます。Framework preset は None、Build command は空、出力ディレクトリは / です。main に push すると、そのまま公開されます。リポジトリに CI の設定はありません。
公開してすぐに 1 つ問題が出ました。Cloudflare Pages は、存在しない URL にトップのページを返します。そのため /games/ で開くとハブが表示され、ハブの中の相対パスの画像が /games/games/... を探して切れていました。直し方は 3 つです。
- ハブのリンクと画像をルートからのパスにする
/games/にはトップへ移動するだけのページを置く404.htmlを置いて、存在しない URL には「この屋台はありません」を出す
定期実行の手順
10 月 18 日に、それまでの作業で使っていた共通仕様、OGP の撮影スクリプト、候補リストを .dev/ にまとめました。それ以降の 12〜20 本目は、定期実行で足しています。13 本目からのコミットの間隔は、およそ 4〜5 時間です。
.dev/README.md の「1 本足す手順」は 8 段です。
ideas.mdの未着手から 1 つ選ぶ(希望があればそちらを優先)games/<id>/を作り、.git/info/excludeに一時的に入れておく(push すると即公開なので、作りかけを Git に入れない)- エージェントに
spec.mdと題材を渡して作らせる - 実際に遊んで確かめる(Playwright。3D は swiftshader)
compose.mjsでogp.pngとthumb.pngを作る- ハブの
GAMESに 1 件足し、README の表にも足す - ハブのスクリプトを
node --checkで構文チェックする - exclude から外して commit、push、本番で表示を確かめる
手順を進める側と、ゲームを書く側が分かれています。ゲームを書くエージェントは games/<id>/index.html だけを作り、commit も push もしません。仕上がったら、ハブ用の情報、遊び方とスコアの仕組み、テストで確かめたことと確かめられなかったこと、OGP 撮影の操作列を短く報告します。ハブへの追加、画像、公開は、手順を進める側がやります。
ゲームを書くエージェントが、渡される前に自分で確かめることも、共通仕様に書いてあります。
- 400×800 で、タイトル → プレイ → 結果 → 即リトライまで操作できる
pageerrorが出ないlocalStorageに記録が残る- 横スクロールが出ない
- スクリーンショットを撮って見た目を自分で確かめ、おかしければ直す
壊れたことと、その見つけ方
壊れて直したことのいくつかは、コミットメッセージに残っています。
ハブの一覧が丸ごと消えた。 ハンガーキャッチの説明文を差し替えたときに、GAMES の行末のカンマが抜けていました。ハブのスクリプトが構文エラーになり、ゲームの一覧が表示されなくなっていました。1 本のゲームの説明を直しただけで、全部の入り口が消えるのがハブの怖いところです。手順 7 の node --check には、「カンマ抜けで一覧が消えた前例あり」と添えてあります。
音が耳に痛い。 ハンガーキャッチの、竿や床に当たる音が 2〜5kHz の金属音で、外れた 1 本で 21 回鳴っていました。低めの「コトッ」「カタン」に変え、強い衝突だけ、0.25 秒以上あけて、1 本あたり最大 4 回にしました(外れ 1 本で 21 音から 4 音)。共通仕様の「耳に痛い高音(2kHz 以上のノイズや高い音の連打)は使わない」は、この直しのあとの 10 月 18 日に入ったルールです。それ以降のゲームの音のコードには、たとえば「音(木の音。低めでやわらかく、2kHz 以上は使わない)」という見出しが付いています。
判定が甘い・決着しない。 ハンガーキャッチの成功判定を「ぶら下がって落ち着いたら」に厳しくしたのと同じコミットで、竿に絡んだまま止まらないことがあったのを、8 秒で必ず決着するようにしています。ボトルフリップの判定にも、7 秒で「時間切れ」にする条件があります。
見つけ方をまとめると、構文はハブだけ node --check、動きと見た目は Playwright で遊ばせてスクリーンショット、難しさはボットの成功率、です。音の聞こえ方や「遊んで気持ちいいか」は、自動の確認ではまだ見られていません。
書いてみて
こうして並べると、自分の決めたことがいちばん濃く残っているのは、.dev/ の仕様と手順です。ゲームの数が増えるほど、1 本のコードよりも、仕様の 1 行と手順の 1 段のほうが効くようになりました。
候補リストは 2026-10-24 に 30 本足していて、風車まわしとお手玉もそこから出ています(末尾の更新を見てください)。
更新(2026-10-20)
この記事は 20 本目までの時点で書いていて、そのあとに 4 本が増えて 24 本になりました(ペン回し・積み木抜き・缶つみ・金魚すくい)。上の本文の「候補リスト」の文を直しています。
使っているものは、次のとおりです。
| ゲーム | 物理 |
|---|---|
| ペン回し | 自前(支点まわりの剛体と親指のばね壁)。three.js は描画だけ |
| 積み木抜き | Rapier |
| 缶つみ | Rapier |
| 金魚すくい | 自前(水の抵抗・紙の濡れ・金魚の群れ)。three.js は描画だけ |
4 本とも、これまでと同じ 1 ファイル完結で、games/<名前>/ に index.html・ogp.png・thumb.png を置く形です。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。
更新(2026-10-22)
さらに 5 本が増えて 29 本になりました。コマ回し・メンコ・竹馬・おはじき・凧あげで、どれも Rapier や matter.js は使わず、物理は自前です。three.js は描画だけに使っています。
| ゲーム | 物理 |
|---|---|
| コマ回し | 自前(歳差・起き上がり・摩擦の剛体) |
| メンコ | 自前(風圧と薄い板の剛体) |
| 竹馬 | 自前(倒立振子と竹の先の接地) |
| おはじき | 自前(円盤の衝突・摩擦) |
| 凧あげ | 自前(平板の揚力・糸の張力・突風) |
5 本とも、games/<名前>/ に index.html・ogp.png・thumb.png を置く 1 ファイル完結の形は同じです。これまでと同じく、ハブの index.html の一覧と README の表にも 1 行ずつ足しています。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。上の「候補リストの残り」の文を直しています。
さらに 5 本が増えて 34 本になりました(2026-10-21〜22)。石積みだけが Rapier で、あとの 4 本は自前の物理です。どれも three.js は描画に使っています。
| ゲーム | 物理 |
|---|---|
| ヨーヨー | 自前(ひもと回転・ねじれ) |
| 石積み | Rapier |
| 羽根つき | 自前(羽根の空気抵抗と板の衝突) |
| 紙相撲 | 自前(板の振動と脚の摩擦) |
| ビー玉転がし | 自前(球の転がり・壁・穴の縁) |
石積みの物理は、積み木抜きや缶つみと同じく、/*PHYS-BEGIN*/ と /*PHYS-END*/ で囲んだ「three には頼らない。Rapier の剛体だけ」の部分に入っています。コードのコメントには、次のような判断が書いてあります。
- 石は
roundCuboid(角を丸めた箱)で作る。convex hull は、Rapier 0.12 で接触が不安定だった(缶つみの報告)ので使わない。 - Rapier 0.12 の
roundCuboidは、質量を丸めの内側の箱だけで数える。そのままだと丸い石ほど軽く回りやすくなるので、質量と回りにくさは自分で入れている。 - 乗ったか崩れたかは、Rapier が返す位置だけで決める。どれか 1 個でも台石より下に落ちたら終わり。
5 本とも、games/<名前>/ に index.html・ogp.png・thumb.png を置く 1 ファイル完結の形です。ハブの index.html の一覧と README の表にも 1 行ずつ足しています。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。
更新(2026-10-23)
さらに 5 本が増えて 39 本になりました(2026-10-22〜23)。将棋倒しだけが Rapier で、あとの 4 本は自前の物理です。どれも three.js は描画に使っています。
| ゲーム | 物理 |
|---|---|
| 長縄跳び | 自前(質点とばねの鎖の縄) |
| 将棋倒し | Rapier |
| 雪合戦 | 自前(放物線・風・当たり判定) |
| 鉛筆ころがし | 自前(六角柱の転がり) |
| 水風船キャッチ | 自前(膜と水の揺れ・張力) |
5 本とも、games/<名前>/ に index.html・ogp.png・thumb.png を置く 1 ファイル完結の形です。ハブの index.html の一覧と README の表にも 1 行ずつ足しています。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。上の「候補リストの残り」の文を直しています。
更新(2026-10-25)
さらに 5 本が増えて 44 本になりました(2026-10-23〜24)。Rapier を使ったものはなく、5 本とも自前の物理です。
| ゲーム | 物理 |
|---|---|
| 風車まわし | 自前(見かけの風と回転・息) |
| お手玉 | 自前(布袋と手のひらの接触) |
| ところてん突き | 自前(やわらかい紐の束) |
| 傘まわし | 自前(回転面を転がる球とジャイロ) |
| 打ち水 | 自前(水滴の粒子と濡れ・乾き) |