オンライン対戦は、端末の言うことを何も信用しない:手・持ち時間・勝敗を、サーバーが決める設計
寝返り将棋 制作の記録 / 2026年9月
寝返り将棋には、知らない人と対戦するオンライン対戦があります。登録もログインもありません。ボタンを押すと、同じように待っている人と組まれます。
ネットで対戦するゲームを作るときに、最初に決めたことがあります。端末(クライアント)の言うことは、何も信用しない、です。端末は、持ち主が好きなように書き換えられます。「今の盤はこうです」「私の残り時間は 2 分です」「私が勝ちました」と、嘘を送ることができます。この記事は、そう言われても困らないように、サーバーをどう作ったかの記録です。数字と仕組みは、サーバーのコードに書かれている、実際の値です。
これは、仕組みの記録です。人どうしの対局での釣り合いは、まだ十分には確かめられていません(ルールを AI 同士の対局で決めた記録を参照)。
原則:サーバーが決める。端末は「やりたい手」を送るだけ
端末がサーバーに送れるのは、次の 3 種類のメッセージだけです。
join:この対局に参加します(署名つきの身分証を添えて)。move:この手を指します。resign:投了します。
盤の状態も、残り時間も、勝敗も、端末からは送れません。送られてきた手は、サーバーが持っている盤に、ルールエンジンで当てはめて確かめます。このルールエンジンは、画面(端末側)と同じファイルです。ルールの判定が 1 か所にしか書かれていないので、画面で「指せる」手が、サーバーでは「反則」になる、という食い違いが起きません。
1 対局 = 1 つの小さなサーバー
サーバーは Cloudflare Workers で動いています。1 つの対局は、Durable Object(状態を持てる小さなサーバー)1 個が受け持ちます。2 人はそこに WebSocket でつながります。対局の途中経過(手の記録と、残り時間)は、手が確定するたびに、その場で保存します。
Durable Object は、使われていないと眠ります。眠って起き直したときには、保存してある手の記録を最初から指し直して、盤を復元します。盤そのものを保存するより、手の記録だけを保存しておくほうが、食い違いが起きません。
手を受け取ったときに、サーバーがすること
手が届くと、順番に、こう確かめます。
- 先に、時間切れを見る。手番の側が持ち時間を使い切っていたら、その手は通さず、時間切れ負けにします。
- 手番を見る。相手の番に送ってきた手は、「あなたの手番ではありません」と返します。
- ルールで検証する。反則の手なら、その人の負けにします(相手の勝ち。理由は「反則」)。
- 通った手を反映する。盤を進めて、手を記録に足し、時計を進めて、保存します。
- 両者に知らせる。指された手、裏返った駒の順序、連鎖の数、新しい残り時間を、2 人に送ります。
反則の手は、黙って捨てずに、負けにしています。
持ち時間は、サーバーだけが持つ
持ち時間は各 3 分、1 手ごとに 5 秒を足す方式です(フィッシャー方式)。時計はサーバーにだけあり、端末に出ている残り時間は、サーバーが送った数字を表示しているだけです。
// 着手が確定したとき
const used = now - clock.turnStartedAt;
clock.remaining[mover] = Math.max(0, clock.remaining[mover] - used) + INCREMENT_MS;
clock.turnStartedAt = now;
使った時間を引いて、5 秒を足して、相手の番を始めます。残りが 0 を下回らないように、Math.max(0, …) を挟んでいます。
もう 1 つの問題は、誰も手を指さないとき、時間切れを誰が見つけるかです。相手が手を送ってこなければ、サーバーは何も動きません。そこで、アラーム(指定した時刻にサーバーを起こす仕組み)を使っています。次に起こす時刻は、「手番の側の持ち時間が尽きる時刻」と「切断の猶予が切れる時刻」の早いほうです。ただし、短すぎる間隔にならないよう、最低でも 1 秒後にしています。
切断は、30 秒待つ
スマートフォンの通信は、よく切れます。電波が一瞬途切れただけで負けにするのは、ひどいので、切断されても30 秒は待ちます。その間、相手には、切断したことと、待つ時間の長さが伝わります。30 秒以内に戻ってくれば、続きから再開します。戻ってくると、サーバーは手の記録と時計の状態を全部送り直す(同期する)ので、画面は途切れた時点に追いつきます。戻らなければ、時間切れ負けです。
同じ人が、別の画面で同じ対局を開き直した場合は、古いほうの接続を閉じます。2 つの画面から別々の手が届いて、盤が食い違うのを防ぐためです。
登録なしで、本人を見分ける
ログインがないので、サーバーが「あなたは、あの ID の人です」と証明する仕組みが要ります。サーバーが ID を発行し、その ID に秘密鍵で署名したもの(HMAC-SHA256)を付けて、「ID.署名」という形の札(トークン)を渡します。端末は、これを自分の中に保存します。
他人の ID を名乗るには、その ID の正しい署名が必要です。署名は秘密鍵がないと作れないので、名乗れません。署名の照合には、一致しているかを最後の文字まで見る比較を使っています。普通の比較は、最初に違った文字で打ち切るので、その時間の差から署名を推測される恐れがあるためです。
この札は、端末に残っている間だけ有効です。端末のデータを消すと、別の人として扱われます。登録がない代わりの、割り切りです。
マッチング:近いレートから探して、待つほど広げる
待っている人のレート(実力の数字)が近い相手と組みます。探し始めは±100。待ち時間が 10 秒たつごとに、許す差を100 ずつ広げます。待たせすぎないためと、実力が近い人と当たるようにするための折衷です。
2 人が組めるかは、どちらか一方でも「自分の許す差」に収まっていれば、組みます。長く待っている側の条件が優先されるので、待ち続けて誰とも組めない、ということが起きにくくなります。待っている人が複数いるときは、長く待っている人から順に見て、いちばんレートの近い相手を選びます。
先手と後手は、レートの低い側を先手にしています。先手は少し有利なので、弱い側が有利な先手を持つ形になり、差が部分的に埋め合わされます。
レーティング:Elo を、対局前の数字で
レーティングは Elo 方式です。最初は 1500。自分の期待勝率は、次の式で出します。
1 / (1 + 10 ** ((相手のレート - 自分のレート) / 400))
動く幅(K 値)は、対局数が少ないうちは大きくします。30 局までは 40、100 局までは 20、それ以降は 10。実力に早く近づくためです。
気をつけたのは、2 人のレートを順に更新しないことです。先に片方を更新してから、もう片方を計算すると、更新後の相手のレートを使ってしまい、不公平になります。両方とも、対局前のレートで計算しています。
順位表に載るのは、10 局以上遊んだ人だけです。1 局だけ遊んで、運よく高いレートで止めておく、という操作を避けるためです。
どう確かめているか
- 単体テスト:ルール、AI、サーバー側の持ち時間・レーティング・マッチング・署名を、126 件のテストで確認しています。
- 通しのテスト:ブラウザを 2 つ立ち上げて、実際にオンライン対局を成立させ、サーバーに直接つないだ動作も確かめるテストがあります。
まだ確かめていないこともあります。実際に知らない人どうしが大勢で遊んだときの挙動と、先手と後手の勝率の差(AI 同士の対局では 61%)が、人間でも同じかどうかです。
分かったこと
端末の申告は、すべて「お願い」。決めるのは、サーバー。
- 時間も、盤も、勝敗も、サーバーが持つ。端末に持たせた瞬間に、書き換えられます。
- ルールの判定は、画面とサーバーで同じものを使う。別々に書くと、いつか食い違います。
- 切れることを前提にする。スマートフォンの通信は切れます。待つ時間と、戻ってきたときの復元を、先に決めておきます。
- 順に更新すると、不公平になる。レーティングは、両者とも対局前の数字で計算します。
この記事の数字(持ち時間、猶予、マッチングの幅、K 値、テストの件数)は、寝返り将棋のサーバーのコードと README から書きました。遊べるのはこちらです。
この話に出てくるもの
- 寝返り将棋 — 6×6 の盤で、挟まれた駒が敵に寝返り、出世する将棋
- 寝返り将棋のルールを、AI 同士の対局で決めた — 同じゲームの、ルールの調整の記録
- 10秒ピッタリのランキングを、公平にする — 同じ作者の、別のゲームのランキングの設計
ほかの記録
- 寝返り将棋のルールを、AI 同士の対局で決めた:効いた 1 つの変更と、壊れた 6 つの案 — 6×6 の盤で挟んで裏返す将棋。ルールの数値を勘で決めず、AI 同士の対局を 40〜100 局ずつ回して測った。反転が 2% から 81% に増えた変更、玉の守りを外すと 17 手で終わった実験、直せていない先後の差まで。
- Qiita の「投稿頻度が高過ぎます」を測った:新しいアカウントは、投稿から約 30 分、次の記事を作れなかった — Qiita の投稿頻度の制限を、5 分おきの記録で測った。新しいアカウントでは、2 回とも、投稿から約 30 分。3 回目は記録が欠けた。
- Claude API から Gemini API(gemini-3.1-flash-lite)へ移行した:16 か所の呼び出しを 1 つの関数にまとめた — Anthropic の利用上限で予定外の課金が出たため Gemini へ移行。モデルの選び方(廃止予定日)、呼び出しの一本化、Web 検索の回数上限が無い問題への回路遮断器、止めるスイッチの記録。
コメント
感想や、記事の間違いの指摘は、ここへ。書くには、GitHub のアカウントが要ります。
押すと、giscus(giscus.app)と GitHub に接続します。押さなければ、接続しません。詳しくはあつかいに書いています。