カードで動かし、盤で読み合う。 そんなカードゲーム×ボードゲーム「Chessive(チェッシブ)」の体験版を公開しました。ブラウザでそのまま遊べます(無料・インストール不要・スマホ対応)。
👉 https://gunghi.kn-clinic.org/chessive/
まだ体験版の段階です。 ルールのバランス、見た目(カードのイラストは仮のドット絵)、遊べるモードは、遊んでくれた人の感想をもとに調整を続けています。気になった点は、ゲーム内の「感想を送る」から教えてもらえるとうれしいです。
この記事では、
- どうやってこのゲームが生まれたか(ルールを GPT と議論して、何度も却下して決めた話)
- このゲームの面白ポイント
- 実装の工夫(同時解決アルゴリズム、2手読みCPU、サーバー権威のオンライン対戦、素材ゼロのドット絵とチップチューン、決定的に書き出すトレーラー…)
- これからゲームを作る人に役立ちそうな知見
を、スクショ多めでまとめます。
ルールの議論は GPT(Codex)と、実装は Claude Code と一緒にやりました。アイデアから公開まで、だいたい3日です。
1. Chessive ってどんなゲーム?
ルールは30秒で説明できます。
- 盤は 5×5。駒は 王・飛車・角・歩×2(将棋でおなじみの動き)。
- 手札のカードには 「動かせる駒」 が書いてある。どこへ動かすかは自分で決める。
- お互いカードを1枚選んで、同時に公開。駒がいっしょに動く。
- 相手の 王のライフ(♥3つ)を0 にしたら勝ち。
相手も同時に動くので、狙った駒が逃げる(空振り) こともあるし、同じマスに入ろうとしてぶつかる こともあります。ぶつかったら、ラウンドごとに交代する ★優先権 を持っている方が勝ちます。
そして、デッキに1枚だけ入っている切り札が 【桂馬】。王以外の駒を、その一手だけ将棋の桂馬の動きで 跳ばせる カードです。
スマホでもそのまま遊べます。
2. どうやって生まれたか:GPT とのルール討論
最初に考えていたのは、こんな漠然としたアイデアでした。
チェスみたいなボードゲームとカードゲームを組み合わせた新感覚ゲーム。駒の動かし方をカードで決め、相手を詰ませる。
でも当然普通のチェスだと運ゲーになっちゃうので、単なるチェス的な詰ませ方にもう一段階必要だと思っている
これを GPT に投げて、具体化してもらいました。ここからが長かったです。
案1:王手を「予告」するゲーム → 「爽快感がない」
GPT の最初の案は、王を取る一手だけは事前に予告が必要 というものでした。隠していたカードで奇襲しても、勝利を決める一手は見えている。理屈としてはきれいです。
でも、私が大事にしたかったのは 爽快感 でした。
単に攻防を予告しあうだけでは派手さがなく、ゲームとしての楽しさは作れないかなと
案2:押し出し・受け流しのコンボ → 「じゃんけんと一緒」
次に出てきたのが、突進・受け流し・踏ん張り のようなカードで駒をぶつけ合い、衝突から連鎖を起こす案です。私も「王だけライフ制にする」「お互い同時に手を出す」「相手の手でコンボが生まれる」とアイデアを足して、ポケモンのような同時ターンバトル の方向に進みました。
ところが、GPT に画像つきで説明してもらった瞬間に違和感がはっきりしました。
うーん、却下かな
じゃんけんと一緒すぎて、じゃんけんでいいとなる
残したいのは、チェスや将棋にある、駒特有の動き、というところ
カードに「受け流し」のような 相性 が書いてあると、結局「相手が突進するか、しないか」の当て合いになります。盤面を読む意味が薄れる んです。
転換点:カードには「動かす駒」だけ書く
ここで方針を自分で決め直しました。
カードは動きが書いてあるのではなく、動かす駒だけ書いてあり、どこに動かすかはプレイヤーが決める、にした方がいかせるかも
- 駒の動きは固定(飛車は縦横、角は斜め)。だから盤面から相手の狙いが読める。
- 手札で「今動かせる駒」が変わる。だから毎試合同じにならない。
- 同時に動くときは 優先権 で決着。動かない敵は優先権に関係なく取れる。
- 駒の補充・位置交換・ドローのカードで、作戦の幅を出す。
GPT はこれを 「手札で動かせる駒が変わる、同時行動の将棋」 とまとめてくれました。
決め手:桂馬だけをカードにする
最後のひらめきがこれです。
あえて桂馬だけカードにして、相手の駒が突然桂馬の動き方をしてくる(ただしデッキに1枚までとか)
ここで桂馬使うかーみたいな
普段は駒の動きが決まっているからこそ、それを一度だけ破る切り札が映える。 ここでゲームの核が固まりました。GPT の言葉を借りると 「盤面で考えさせて、切り札で驚かせる」 です。
この議論から学んだこと
- AI はアイデアを大量に出してくれるが、「何を残したいか」を決めるのは人間。 案を却下するたびに、自分のやりたいことがはっきりした。
- 画像で説明してもらうと、違和感に気づける。 文章では良さそうに見えた案が、絵にした瞬間「じゃんけんだ」と分かった。
- 最後に 草案(idea.md) に落としてもらった。盤面サイズ、デッキ構成、未確定事項、「試遊でこういう問題が出たら、まずこれを変える」という調整表 まで入っている。これが実装の設計書になった。
3. 草案から公開まで(Claude Code との3日間)
草案の Markdown を Claude Code に渡して、「試遊用に実装できる?」から始めました。
| 段階 | やったこと |
|---|---|
| 1日目 | ルールエンジン、CPU、ドット絵、BGM。CPU 同士の自動対戦でバランスを確認 |
| 2日目 | 遊んで気になった点を直す(駒の見た目、演出、ルール調整)。オンライン対戦サーバー、既存サーバーへの公開 |
| 3日目 | チュートリアル、攻撃演出、スマホ対応、SEO、アイコン、紹介動画 |
流れはずっと 「遊ぶ → 気になったことを一言で伝える → すぐ直る → また遊ぶ」 の繰り返しでした。「飛車と角が似すぎ」「王冠を中くらいに」「到達って名前ダサい」くらいの粒度で伝えて、数分で反映されます。この速さのおかげで、試遊の熱が冷めないうちに直せた のが大きかったです。
4. 面白ポイント
同時公開の読み合い
相手の駒の動きは決まっているので、「この角が動くなら、ここが空く」「動かないなら、こっちで取れる」 と、複数の可能性に対して得をする配置を考えられます。じゃんけんではなく、盤面に根拠のある読み合いです。
王を討つ一撃
王は取られない代わりに、攻撃されるとライフが1減ります。一度の読み負けでは終わらないけれど、決まったときの手応えは大きい。ここは演出もいちばん派手にしました。
捨て札から相手を読む
使ったカードは公開されます。「相手はもう桂馬を使った」「角のカードはまだ3枚残っている」が分かるので、カードゲームらしい読みも効きます。
歩の「突破」
歩が相手の一番奥の列に着くと「突破」。2回で相手の王に1ダメージです。王を直接狙うだけでなく、歩で押し込む 勝ち筋もあります。
5. 実装の工夫
ここからが Qiita らしいところです。フレームワークもビルドも使わず、素の JavaScript だけ で作っています(全体で約5,300行)。
index.html / rules.html / feedback.html
js/rules.js ルールエンジン(DOM なし)
js/ai.js CPU(やさしい/ふつう/つよい)
js/ui.js 画面・入力・演出
js/sprites.js ドット絵(文字列から描く)
js/audio.js BGM・効果音(WebAudio で合成)
js/net.js オンライン対戦・計測・アンケート
server/ 対戦サーバー(Node.js + ws)
tests/ ルールのテストと CPU 同士の自動対戦
trailer/ 紹介動画を書き出すページ
5-1. ルールエンジンは DOM から切り離して、どこでも同じコードを使う
rules.js は画面のことを一切知りません。ブラウザでは window.Rules、Node.js では require で読めるようにしてあります。
(function (root) {
// …ルール本体…
root.Rules = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
この1ファイルを、画面・CPU・自動テスト・オンライン対戦サーバー の4か所で共有しています。
- オンライン対戦では、サーバーがこのルールで勝敗を計算するので、画面とサーバーで判定がずれない。
- 後で説明するバランス調整のシミュレーションも、ブラウザなしで回せる。
ゲームのルールは、最初から「画面なしで動く」形にしておく。 これが一番効いた設計判断でした。
5-2. 同時解決は「結果が変わらなくなるまで」繰り返す
同時行動のゲームで一番やっかいなのが、解決の順番 です。例えばこんな場面があります。
- 自分の飛車が、相手の王を攻撃する(王はライフ-1、飛車は元の位置に戻る)
- 同時に、相手の駒が「飛車がいた場所」を狙っている
飛車は動いたのか、動いていないのか? Chessive では 「王を攻撃して戻った駒は、動かなかった駒として扱う」 と決めました。すると、その場所を狙った相手の駒は「動かない敵を攻撃した」ことになり、飛車を取れます。
ただ、ある駒が「動かなかった」ことになると、別の駒の判定も変わる ことがあります。そこで、判定が変わらなくなるまでループしています。
// 止まっている敵の王を攻撃した駒、止まっている味方に塞がれた駒は「動かなかった」扱い。
// それにより別の駒の判定が変わるので、安定するまで繰り返す。
let changed = true;
while (changed) {
changed = false;
for (const m of [...active]) {
const occ = pieceAt(s, m.to.x, m.to.y);
if (!occ || gone.has(occ.id) || isActive(occ)) continue;
if (occ.owner === m.owner) {
m.outcome = 'blocked'; // 味方に塞がれた
} else if (occ.type === 'king') {
m.outcome = 'bounce'; // 王を攻撃して戻る
m.hitKing = occ.owner;
} else continue;
active.delete(m); // 「動いている駒」から外す
changed = true;
}
}
この方式は、「号令」(歩が全員同時に1マス進む)のように 1人が複数の駒を同時に動かすカード を足したときも、そのまま使えました。
テストも、こういう「ややこしい場面」を1つずつ書いています。
// 王を攻撃して戻った駒は「止まっている」→ その元の位置を狙った敵に取られる
{ const s = blank(); put(s,0,'king','A5'); const k1 = put(s,1,'king','C1');
const a = put(s,0,'rook','C3'); const b = put(s,1,'rook','E3');
R.resolveRound(s, [mv(s,a,'C1'), mv(s,b,'C3')]);
assert.strictEqual(s.players[1].life, 2); assert(!R.getPiece(s,a.id)); assert.strictEqual(at(b),'C3'); }
5-3. バランスは「CPU 同士に数百試合させて」数字で決める
ルールを変えるたびに、CPU 同士で数百試合を自動で回して比べました。node tests/sim.js 300 old new pair tally のように、設定の名前を並べるだけで比較表が出ます。
例えば、歩の突破ルールは3案を比べました。
| ルール | 平均ラウンド | 突破が王ダメージに占める割合 | 40ラウンドの上限まで |
|---|---|---|---|
| 1体着いたら1ダメージ | 15 | 37%(強すぎ) | 0% |
| 2体そろったら1ダメージ | 22 | 5%(弱すぎ) | 13% |
| 突破2回ごとに1ダメージ(採用) | 20.5 | 14% | 4% |
「2体そろったら」は直感的には良さそうでしたが、数字を見ると 先に着いた歩が相手の王の隣で待たされて、ほぼ必ず取られる ことが分かりました。
一度は追加した「突撃(飛車・角が距離無制限)」も、遊んで「初手で飛車が角を取れるのは理不尽」と感じたので、数字で確かめてから外しました。
| デッキ | 8ラウンド以内に決着 | 先手・後手の勝ち数 |
|---|---|---|
| 突撃あり | 10% | 148 対 124 |
| 突撃を外して角を1枚戻す | 3% | 139 対 139 |
遊んだ人の「なんか理不尽」を、数字で確かめてから直す。 この往復が、短期間でも納得感のある調整につながりました。
5-4. CPU の「2手読み」は、相手の手札を見ずに「相手がやりそうな手」を重み付けする
CPU は3段階です。「ふつう」は、相手が動かない前提で1手先を読みます。「つよい」は、相手の行動も予測 します。
ただし、相手の手札をのぞき見するのはズルです。そこで、相手の駒が盤面上でできる手をすべて挙げて、相手目線で評価し、良さそうな手ほど起きやすいとみなします(softmax で重み付け)。
function opponentModel(s, o, L) {
const op = 1 - o;
const acts = [{ kind: 'pass', owner: op }];
for (const p of s.pieces) {
if (p.owner !== op) continue;
for (const to of R.normalMoves(s, p)) acts.push({ kind: 'move', owner: op, pieceId: p.id, from: { x: p.x, y: p.y }, to });
// 桂馬がまだ除外されていなければ、桂馬の跳びも候補に入れる …
}
// 相手目線の1手読みで評価し、上位 K 手を softmax で重み付けする
const scored = acts.map((a) => ({ a, v: evaluate(simulate(s, op, a, pass), op) })).sort((x, y) => y.v - x.v);
const top = scored.slice(0, L.K);
const ws = top.map((x) => Math.exp((x.v - top[0].v) / L.T) + (x.a.kind === 'pass' ? L.passW : 0));
const sum = ws.reduce((a, b) => a + b, 0);
return top.map((x, k) => ({ a: x.a, w: ws[k] / sum }));
}
ここで面白かったのは、最初の「つよい」は「ふつう」に負けた(79勝102敗)ことです。相手の予測を鋭くしすぎると、「相手は最善手を打ってくるはず」と考えて守りに偏ってしまう。予測を広めにし(上位16手、温度高め、相手が様子見する可能性も残す)、さらに 有力な10手だけは自分の次の手まで読む ようにして、ようやく 189勝84敗 になりました。
5-5. オンライン対戦は「サーバーが審判」+「後手には盤を180度回して送る」
オンライン対戦(ランダムマッチと部屋コード対戦)は、Node.js と ws で作っています。
設計のポイントは2つです。
1つ目:勝敗の計算はすべてサーバー。 クライアントから来た行動は、そのまま使いません。サーバー側でその時点の 合法手の一覧を作り、その中に一致するものがあるか を確かめて、サーバーが作った正式な行動に置き換えます。
return R.legalActionsForCard(s, v, card).find((l) => {
if (l.kind !== a.kind) return false;
if (l.kind === 'move' || l.kind === 'knight') return l.pieceId === a.pieceId && same(l.to, a.to);
if (l.kind === 'reinforce') return same(l.to, a.to);
if (l.kind === 'swap') return (l.a === a.a && l.b === a.b) || (l.a === a.b && l.b === a.a);
return true;
}) || null;
2つ目:プレイヤーごとに「見え方」を作って送る。 相手の手札と山札の中身は、枚数だけ残して伏せます。さらに後手のプレイヤーには 盤を180度回転させ、プレイヤー番号も入れ替えて 送ります。
function viewState(canon, v) {
const s = clone(canon);
s.players.forEach((P, o) => {
P.deck = hidden(P.deck.length, -1000 * (o + 1)); // 山札の中身は誰にも見せない
if (o !== v) P.hand = hidden(P.hand.length, -100 * (o + 1)); // 相手の手札は枚数だけ
});
if (v === 1) { // 後手には盤を回して「自分=0番」に見せる
s.players.reverse();
s.priority ^= 1;
for (const p of s.pieces) { const f = flipPos(s, p); p.x = f.x; p.y = f.y; p.owner ^= 1; }
}
return s;
}
こうすると、クライアントは常に「自分が下側のプレイヤー0」として動けばいい ので、CPU 戦の画面コードをほぼそのまま使い回せました。マス名(A1 など)だけは、どちらから見ても同じになるように変換しています。
切断しても2分以内なら自動で復帰でき、戻らなければ不戦敗です。2つの接続を使って「部屋対戦 → 再戦 → ランダムマッチ → 切断と復帰」を最後まで通す自動テストも用意しました。テストでは、相手の手札が見えていないこと と 2人の盤面が180度回転の関係で一致していること も毎ラウンド確かめています。
5-6. 素材ファイルゼロ:ドット絵は文字列、BGM は WebAudio で合成
イラストは後から差し替える前提で、仮の素材を全部コードで作りました。
ドット絵は、16×16 の文字列で描いています。例えば飛車のミニシルクハットと羽はこうです(o=輪郭、w=白、p=ピンク)。
rook: {
0: '.......oooo.....',
1: '.......owwo.....',
2: '.......owwo.....',
3: '.....poppppo....', // ピンクのリボン
4: '....owwwwwwwo...', // 帽子のつば
9: '.oo..........oo.', // ここから羽
10: 'owwo........owwo',
// …
},
共通の体に、駒ごとの「頭の飾り」を上書きして作ります。「飛車と角が似すぎ」「王冠を中くらいに」といった見た目の調整も、この文字列を数行書き換えるだけでした。
BGM と効果音は、音声ファイルを使わず WebAudio でその場で合成 しています。ファミコン風の音色は、デューティ比つきの矩形波を PeriodicWave で作ります(パルス波のフーリエ係数)。
function pulse(duty) {
const n = 32, re = new Float32Array(n), im = new Float32Array(n);
for (let k = 1; k < n; k++) {
const a = 2 / (k * Math.PI) * Math.sin(k * Math.PI * duty);
re[k] = a * Math.cos(k * Math.PI * duty);
im[k] = a * Math.sin(k * Math.PI * duty);
}
return ctx.createPeriodicWave(re, im);
}
楽譜も文字列です(1トークン=8分音符、- は伸ばす)。
'D5 - G5 - B5 - A5 G5', 'F#5 - A5 - D5 - - -', 'E5 - G5 B5 A5 - G5 E5', 'G5 - - E5 C5 - - -',
王のライフが1になると、同じ曲のまま テンポを上げ、キーを上げ、ドラムを激しくする「盛り上がりモード」 に、次の小節の頭から切り替わります。自分がピンチのときは、同じメロディを短調に読み替えて(シ→シ♭、ミ→ミ♭)不穏にしています。曲を何曲も作らなくても、場面に合わせて表情を変えられるのが合成の強みです。
5-7. 気持ちよさの演出:時間で進むエフェクト+ヒットストップ+ズーム
攻撃の演出は、「溜め → 突進 → 命中 → 吹き飛ばし」 の4段階です。王への一撃だけは、命中の瞬間に 0.13秒止める(ヒットストップ) →赤い×斬り→盤を王に寄せるズーム、と特別扱いしています。
エフェクトは「開始時刻・長さ・描く関数」を配列に積んでおき、描画のたびに経過時間から描くだけです。
function addFx(dur, draw, delay) { FX.push({ start: performance.now() + (delay || 0), dur, draw }); }
function drawFx() {
const t = performance.now();
for (let i = FX.length - 1; i >= 0; i--) {
const f = FX[i], p = (t - f.start) / f.dur;
if (p >= 1) { FX.splice(i, 1); continue; }
if (p >= 0) { ctx.save(); f.draw(p); ctx.restore(); }
}
}
ちなみに、アニメーションは requestAnimationFrame ではなくタイマーで進めています。ブラウザのタブが裏にあると rAF が止まり、解決が永遠に終わらない というバグを自動テスト中に踏んだためです。
5-8. 紹介動画は「時刻 t だけから1コマを描く」関数で決定的に書き出す
Steam などに載せる紹介動画(32秒・1920×1080)も、ゲームと同じドット絵と演出で作りました。画面録画ではなく、render(t) が時刻だけから1コマを描く 作りにして、1コマずつ PNG に書き出しています。
async function exportAll() {
await save('audio.wav', wav(await Sound.renderOffline(AUDIO))); // BGM と効果音を時刻どおりに並べて書き出す
for (let i = 0; i < DUR * FPS; i++) {
render(i / FPS);
const blob = await new Promise((r) => cv.toBlob(r, 'image/png'));
await save(`frame_${String(i).padStart(5, '0')}.png`, blob);
}
}
あとは ffmpeg でまとめるだけです。
ffmpeg -framerate 30 -i out/frame_%05d.png -i out/audio.wav -c:v libx264 -crf 16 -pix_fmt yuv420p -c:a aac -shortest chessive-trailer.mp4
- 画面録画と違って コマ落ちしない。何度書き出しても同じ映像になる。
- 音は
OfflineAudioContextで、実時間より速く書き出せる。ゲーム本体の効果音関数を、「今」ではなく指定した時刻に鳴らせる ように少し改造しただけで使い回せた。 - 粒子の散り方も、時刻とシードから計算するので毎回同じ。
5-9. スマホ対応の小ネタ
-
手札は横スクロールさせない。 8枚でも1列に収まるよう、幅が足りない分だけカードを重ねます。重ねる量は「足りない幅 ÷ (枚数−1)」で計算して、
--ovという CSS 変数でmargin-leftに入れるだけです。 - 入口で見せ方を変える。 長い文章はスマホのモーダルでは窮屈なので、「あそびかた」と「感想を送る」は独立したページも作りました。タイトル画面からはそのページへ移動し、プレイ中はモーダルでさっと開く、と入口で使い分けています。独立ページは検索にも引っかかるので、SEO の面でも得です。
- 画面の重なり順は、実際に通しで触って確かめる。 この記事のスクショを撮っていて、タイトル画面から「部屋を作る」と、部屋コードの画面がタイトル画面の裏に隠れる バグに気づきました(直しました)。自動テストではロジックは確かめられても、「見えているか」までは分かりません。
5-10. 既存のサーバーに「相乗り」して公開する
公開先は、別に作っていた将棋系ゲーム(軍儀)のサーバーです。同じドメインの /chessive/ 以下で動かしています。
- Chessive は 別のプロセス・別のユーザー で動かし、nginx が
/chessive/だけを振り分ける。片方が落ちても、もう片方に影響しない。 - アプリは環境変数
BASE_PATH=/chessiveでサブパスに対応。クライアントの API と WebSocket の接続先は、ページの場所からの相対で決める。 - インストールスクリプトは、既存の nginx 設定に1行足す前に控えを取り、
nginx -tが通らなければ全部元に戻す。既存サービスを壊さないことを最優先にした。 - 手元の PowerShell から
deploy.ps1を1回実行するだけで、ファイルを送って入れ直せる。
6. これからゲームを作る人へ:この開発で得た知見
ルール作り
- AI の案は、どんどん却下していい。 却下の理由(「爽快感がない」「じゃんけんになる」)を言葉にするたびに、自分が残したいものがはっきりする。
- 「残したい体験」を最初に書く。 草案には「大切にしたい体験」と「採用しない方向」の両方を書いた。迷ったときの判断基準になる。
- 調整表を最初から作っておく。 「王がすぐ倒れたら、ライフを3→4」のような表は、そのままゲームの設定項目になり、そのままシミュレーションの比較条件になった。
- 未確定のルールは「仮決め」として明記する。 実装を止めずに進められ、遊んでから見直せる。
実装
- ルールは画面から切り離す。 テスト、CPU、シミュレーション、オンライン対戦の土台になる。
- 数字で議論する。 「強すぎる気がする」を、数百試合の比較表で確かめてから直す。
- 素材は後から差し替えられるようにして、最初は全部コードで作る。 見た目と音を数行の修正で直せるので、試遊のサイクルが速い。イラストの指示書(ファイル名・サイズ・キャラクター案)も先に書いておくと、後で外注や画像生成に渡しやすい。
- 演出は面白さそのもの。 ヒットストップ、ズーム、盛り上がる BGM を入れただけで、同じルールでも「決まった!」の手応えがまるで変わった。
公開
- 試遊の仕組みを最初から入れる。 終了時の記録コピー、アンケート、日ごとのアクセス数の管理ページを入れておくと、身近な人以外の反応も拾える。
-
共有されたときの見た目を整える。 アイコン、SNS 用の画像(OGP)、題名と説明文。検索より先に、リンクを貼られたときの見え方が効く。
おわりに
「チェスとカードゲームを混ぜたら面白そう」という一言から、AI と議論して、却下して、遊んで、数字で確かめて、3日で人に遊んでもらえる形になりました。一番大事だったのは、AI に任せきりにせず、「自分がこのゲームで何を感じたいか」を言葉にし続けたこと だと思います。
まだ体験版なので、ここからが本番です。ぜひ遊んでみて、感想を送ってもらえるとうれしいです!










