はじめに
インクの濃さで陣地を奪い合う 1 対 1 のブラウザゲーム Inkmaru を、Go + Ebitengine で書いて WebAssembly として配信しています。CPU 対戦だけだったところに、WebRTC による P2P のオンライン対戦と、同じ相手との再戦を足しました。実装は Claude Code を使って進めています。
この記事は、その過程で調べたこと・実測したこと・あえて諦めたことのまとめです。
- WebRTC の基本(シグナリング、SDP、ICE、STUN/TURN、DataChannel)
- サーバレス(API Gateway WebSocket + Lambda + DynamoDB)で作ったシグナリングサーバの構成
- シグナリングのメッセージは送った順に届かないという実測と、その対策
- 審判のいない P2P で「後出し」と「逃げ得」を潰す設計(commit-reveal・盤面ハッシュ)
- TURN を入れない、ブラウザの WebRTC API を直接叩かず pion(pion/webrtc、Go だけで書かれた WebRTC の実装)を使う、などの割り切り
「WebRTC の DataChannel でちょっとしたリアルタイム対戦を作りたい」人を想定して書いています。
数字は特記がない限り、開発中に実際に測った値です。見込み値は「見込み」と書いています。
1. WebRTC の基本をざっくり
1.1 WebRTC は「P2P で直接つなぐ」ための仕組み一式
WebRTC はブラウザ同士(やネイティブアプリ同士)をサーバを経由せずに直接つなぐための仕組みです。映像・音声のイメージが強いですが、任意のバイト列を送る DataChannel(RTCDataChannel)も WebRTC の標準の機能の 1 つです。映像・音声と同じ RTCPeerConnection の上に作られ、接続を張る手順(シグナリング・SDP・ICE)も同じです。
Inkmaru は映像・音声は使わず、DataChannel だけを使っています。 対戦中のやりとり(後で出てくる commit・reveal・盤面ハッシュ・再戦の申し出)は、すべて DataChannel 1 本で送ります。
ただし「直接つなぐ」までの準備には第三者が要ります。登場人物は次のとおりです。
| 要素 | 役割 |
|---|---|
| シグナリングサーバ | 接続前に、お互いの情報(SDP と ICE candidate)を受け渡す仲介役。WebRTC の仕様の外にあり、方式は自由(WebSocket、HTTP、手でコピペでもよい) |
| SDP (Session Description Protocol) | 「こういう通信をしたい」という提案(offer)と返答(answer)。DataChannel を使うことや暗号化の鍵指紋などが書かれる |
| ICE candidate | 「この IP:ポートなら届くかもしれない」という経路候補。1 台の端末から複数出る |
| STUN サーバ | 「外から見た自分のアドレス」を教えてくれるサーバ。NAT の内側から外向きの候補を作るのに使う。軽いので Google などが無料で公開している |
| TURN サーバ | 直接つながらないときに通信を中継するサーバ。帯域を食うので基本的に有料 |
1.2 接続までの流れ
Inkmaru のシグナリングでは、ブラウザからサーバへの要求は create(部屋を作る)・join(部屋に入る)・signal(相手へ中継してもらう)の 3 種類だけです。A が部屋を作り、部屋番号を口頭などで伝えられた B が入ってくる流れを図にすると次のようになります。
- Trickle ICE: candidate は見つかるたびに 1 通ずつ、すぐ相手へ送るのが普通です。全部集めてから SDP に埋め込む方式(Vanilla ICE)より早くつながります。
- ICE は受け取った候補の組み合わせを片っ端から試し(接続確認)、使える組が 1 つでもあればつながります。
- DataChannel は DTLS(暗号化)の上の SCTP で運ばれます。既定では順序保証あり・再送あり(TCP 的)で、設定で順序なし・再送なしにもできます。
1.3 NAT と「つながらない回線」
家庭のルーターや携帯回線はたいてい NAT の内側です。STUN で外向きアドレスが分かれば多くの NAT は越えられますが、Symmetric NAT(宛先ごとに外向きポートが変わる)や一部のキャリアグレード NAT では、STUN で分かったアドレスが相手には使えません。片側だけが Symmetric NAT で相手側の NAT がゆるければ、相手から届いた接続確認をきっかけにつながることもありますが、両側とも Symmetric NAT のような組み合わせでは直接つなぐ手段が無く、TURN で中継するしかありません。
この「TURN を入れるかどうか」が、後で書く一番大きな割り切りになりました。
2. 全体の構成
ブラウザ側のソースは morststs/inkmaru-web で公開しています(シグナリングサーバ本体のコードは含みません)。図の各パッケージは、リポジトリでは internal/ の下にあります。
2.1 シグナリングサーバ:中身を一切解釈しない中継役
シグナリングは API Gateway の WebSocket API + Lambda(Go)+ DynamoDB で作りました。常時起動するものが無いので、使わない期間の固定費はほぼゼロです(残るのは CloudWatch Logs のわずかな保存料と、後述の非常停止用のアラーム 2 個ぶん、最大で月 $0.20 くらい)。CloudFormation(SAM 記法)で配備しています。
プロトコルは §1.2 の create・join・signal の 3 つだけで、ポイントは signal の data の中身をサーバが一切解釈しないことです。SDP も candidate も不透明な JSON として右から左へ渡すだけ。おかげで、
- クライアント側で交渉手順を変えてもサーバの変更が要らない
- SDP のパーサが無いので、そこを狙われる攻撃面も無い
という利点があります。そのかわり濫用対策として、
-
dataは 16 KiB まで(API Gateway のフレーム上限 32 KiB より小さく) - API 全体で秒間 20 リクエスト(バースト 40)、新しい接続は秒間 2(バースト 5)にスロットリング(WebSocket URL は公開の WASM に埋め込まれるので、既定の上限のままだと叩かれ放題で課金される)。上限は接続ごとではなく全クライアントの合計です。WebSocket API にはクライアントごとの上限(usage plan)も AWS WAF も無いためです
- メッセージ数と接続数の CloudWatch アラームが鳴ったら、Lambda がステージの上限を 0 にして止める(非常停止)
- ログに
dataの中身は出さない
としています。
部屋番号は「人が口で伝える」前提で作る
部屋番号は 6 桁で、0/O、1/I/L を除いた 31 文字から crypto/rand で引いています(約 8.87 億通り)。表示は P2Z-5BS のように 3 桁ずつ区切ります。読み違いを減らすことのほうが、桁数の節約より大事です。
DynamoDB の TTL と $disconnect は当てにしない
-
DynamoDB の TTL は期限ちょうどの削除を保証しません。削除はバックグラウンドで行われ、期限が来てもすぐには消えず、消えるまでの間は普通に読めてしまいます。部屋が生きているかは、毎回サーバ側で
expiresAtと現在時刻を比べて判定し、TTL は容量の後始末としてだけ使います。 -
API Gateway の
$disconnectはベストエフォートです。行儀よく閉じたときは実際にほぼ即時に届きましたが、ブラウザのクラッシュや回線断のときは保証がありません。後始末は TTL(10 分)を主にしました。
2.2 クライアント:判断を持つコードは通信を知らない
このプロジェクトは一貫して「判断を持つコードを外部依存から切り離す」方針で作っています。オンライン対戦でも同じで、
-
netplay(対戦プロトコル)は pion も WebSocket も import しない。Transportという「バイト列を送受信する 1 本の経路」インタフェースだけを知っている -
rtcは pion を包み、Transportを実装する。pion の型を外に出さない -
netwireが両者を配線する。何を import してもよい層を 1 つだけ作って、そこへ寄せる
// Transport は相手との1本の双方向経路。実装は WebRTC の DataChannel。
type Transport interface {
Send(b []byte) error
// Receive は次のメッセージを返す。相手が消えていれば ErrPeerGone を返す。
Receive(ctx context.Context) ([]byte, error)
Close() error
}
こうしておくと、2 つの対戦セッションをメモリ上のパイプでつないで対戦させられるので、commit-reveal も時間切れも不正検出も、実際の通信なしに go test で全部検証できます。WebRTC のコードは「つながる・送れる」だけを、プロセス内で pion のピアを 2 つ立てて検証します(ネットワークもブラウザも不要)。
ゲームループを止めない
Ebitengine の Update() は毎フレーム呼ばれるので、そこで相手の手を待ってはいけません。CPU 対戦では「確定 → その場で CPU の手を計算 → 解決」で済んでいましたが、オンラインではいつ届くか分かりません。そこで相手の抽象を「送る」と「取りに行く」に分けました。
type Opponent interface {
// Submit は自分の手を相手に伝える。ブロックしてはならない。
Submit(mine rules.Circle, turn int) error
// Poll は相手の手が確定していれば返す。まだなら ok=false ですぐ返る。
Poll(turn int) (c rules.Circle, ok bool, err error)
Close() error
}
CPU は Submit で計算して Poll が即 ok=true。オンラインは内部で commit-reveal を回し、揃ったら ok=true。CPU 対戦の挙動とテストはそのまま通り続けます。
3. 工夫した点
3.1 シグナリングの到着順は保たれない(実測)
配備したシグナリングサーバに signal を 20 通連続で送り、届いた順を記録しました。結果はこうです。
[0 1 2 7 5 8 6 4 12 3 9 18 16 17 14 11 19 13 15 10]
4 回の独立した実行すべてで、毎回違う崩れ方で順序が入れ替わりました。CloudWatch を見ると、このバーストの間に同じ関数の実行環境が 0.5 秒足らずで 16 個立ち上がっていました。API Gateway WebSocket + Lambda は signal 1 通ごとに独立した Lambda 実行を起こすので、完了順に何の保証もないわけです。
API Gateway WebSocket + Lambda をシグナリングに使うなら、メッセージの到着順を仮定してはいけません。 一方、欠落・重複はこの実測の範囲では観測されませんでした(保証ではありません)。統合テストも「順序が保たれる」ではなく「全通が一度ずつ届く」を検証する形に書き直しました。
これが何を意味するかというと、ICE candidate が SDP より先に届くのは例外ではなく普通のことだということです。setRemoteDescription の前に addIceCandidate しても失敗するので、素朴に実装すると「たまにつながらない」という一番いやな不具合になります。
対策:遠隔記述が入るまで candidate を緩衝する
rtc の Peer の中に緩衝を閉じ込め、呼び出し側は順序を気にしなくてよいようにしました(以下は実際のコードからコメントを削ったものです)。
// AddCandidate は相手の candidate を受け取る。
// 遠隔記述がまだ入っていなければ溜める。
func (p *Peer) AddCandidate(ci Candidate) error {
p.mu.Lock()
if !p.remoteSet {
p.bufferLocked(ci)
p.mu.Unlock()
return nil
}
p.mu.Unlock()
if err := p.pc.AddICECandidate(ci.toPion()); err != nil {
p.mu.Lock()
p.rejected++
p.mu.Unlock()
}
return nil
}
// SetRemote は相手の SDP を設定し、溜めていた candidate をまとめて流す。
func (p *Peer) SetRemote(sd Description) error {
if err := p.pc.SetRemoteDescription(sd.toPion()); err != nil {
return fmt.Errorf("rtc: 相手の SDP を設定できない: %w", err)
}
p.mu.Lock()
p.remoteSet = true
buffered := p.pending
p.pending = nil
p.pendingSeen = nil
p.mu.Unlock()
// 解釈できないものは飛ばして、残りを全部流す。1つの失敗で止めない。
rejected := 0
for _, ci := range buffered {
if err := p.pc.AddICECandidate(ci.toPion()); err != nil {
rejected++
}
}
if rejected > 0 {
p.mu.Lock()
p.rejected += rejected
p.mu.Unlock()
}
return nil
}
SDP 側も、2 通目の SDP は捨てる(設定し直すと状態が壊れる)、answer 側は offer を受け取ってから answer を作る、という形で順序に依存しないようにしています。
緩衝は「敵対的な入力」として扱う
シグナリングは公開の経路で、こちらが制御できない相手からも何かが届きます。そこで緩衝にも気を配りました。
- 上限を設ける(256 件)。正常な ICE 交換は多くても数十件なので、まともな対戦は触れない
- 溢れたら古いほうから捨てる。新しいほうを捨てると、ゴミの candidate を上限ぶん先に送りつけるだけで、後から来る本物を全部締め出せてしまう
- 同じ candidate は畳む。同じものを連打するのが最も簡単な嫌がらせなので
- 解釈できない candidate が 1 つあっても失敗にしない。ICE は使えるものが 1 つあればつながる設計なので、1 つで対戦全体を潰せるようにしてはいけない
3.2 pion を使うときに気をつけるところ
pion は js/wasm ではブラウザの RTCPeerConnection を包む実装に切り替わります。以下はどちらで動いても安全な書き方です。
OnMessage は DataChannel が開く前に登録する
最初は「Transport() を取り出すときに OnMessage を登録」していました。ところが pion は DataChannel が開いた時点で読み取りを始め、ハンドラ未登録のメッセージは黙って捨てます。相手が先に送ってきて、こちらが 1 フレーム遅れただけで 1 ターン目の commit が消え、対戦が無言で固まります。しかも症状は「相手の時間切れ」に見えるので、原因が追えません。
offer 側は CreateDataChannel の直後、answer 側は OnDataChannel の中で、すぐに受信の受け皿を用意するようにしました。
OnClose はハンドラを 1 つしか持てない
OnClose(f) は追加ではなく代入です。Transport 側と Peer 側でそれぞれ登録すると後勝ちで片方の始末が消えるので、1 か所で両方を片付けます。なお DataChannel の close は SCTP で即座に通知されるので、ICE の切断判定(数秒〜)よりずっと早く相手の離脱に気づけます。
自分の SDP は pc.LocalDescription() から取らない
pc.LocalDescription() はその時点までに集まった candidate を SDP に混ぜ込みます。Trickle で別途送る分と二重になり、どちらの経路でつながったのか分からなくなるので、CreateOffer / CreateAnswer が返した SDP をそのまま送っています。
受信は「届いている分を先に覗く」
ゲームループからは毎フレーム「期限 0 の context」で Receive を呼び、「届いている分だけくれ」という意味で使います。ところが素直に
select {
case b := <-t.in:
return b, nil
case <-ctx.Done():
return nil, ctx.Err()
}
と書くと、Go の select は準備のできた case から擬似ランダムに選ぶので、届いていても ctx.Err() が返ることがあります。まず非ブロッキングで覗いてから待つ形にしました。
func (t *dcTransport) Receive(ctx context.Context) ([]byte, error) {
select {
case b := <-t.in:
return b, nil
default:
}
select {
case b := <-t.in:
return b, nil
case <-t.closed:
return nil, netplay.ErrPeerGone
case <-ctx.Done():
return nil, ctx.Err()
}
}
3.3 審判のいない P2P で公平に対戦する
WebRTC でつながると、サーバはもう対戦に関与しません。つまり審判がいません。ここからはゲーム側のプロトコルの話です。
commit-reveal で「後出し」を防ぐ
Inkmaru は両者が同時に円を描くゲームです。素直に「確定したら円を送る」と、通信を覗けば相手の円を見てから自分の円を決められてしまいます。そこで各ターン、
- まずハッシュ
SHA-256(cx ‖ cy ‖ r ‖ nonce)だけを送り、両者の commit が揃ってから実体を送る - float は文字列化せず
math.Float64bitsのビッグエンディアン 8 バイトで並べる(桁数や指数表記が環境で揺れないように)
nonce が無いと意味がありません。 盤面の判定格子は 256×256、半径の候補も 100 通り程度なので、円の候補は約 655 万通りしかなく、nonce なしのハッシュは総当たりで一瞬で割れます。crypto/rand の 16 バイトを混ぜています。
盤面ハッシュで食い違いを検出する
両者が独立に盤面を計算するので、バグや改造で結果がずれても誰も裁定できません。毎ターン、解決後の盤面(各セルの所有者を 1 バイトずつ並べた 65,536 バイト)の SHA-256 を交換し、一致しなければ中断します。
ここで一度ハマりました。両者が自分を P1 として盤面を作っていたため、盤面が互いの鏡像になり、1 ターン目に必ず不一致で落ちました。席は「部屋を作った側が P1、参加した側が P2」と役割で固定しています(シグナリングがすでに区別しているので追加の通信は不要)。副次的に、両方の画面で色が一致するようにもなりました。
「逃げ得」を消す
commit が揃ったあと、先に reveal した側の手を見てから「不利だから切断する」ことは技術的に防げません。そこで切断は切断した側の負けとしました。逃げても勝てないので、逃げる動機が消えます。
同じ理由で、相手から届いた abort(「お前のハッシュが合わない」という主張)も相手の負けとして扱います。当初は「中断は勝敗なし」にしていましたが、それだと負けている側が abort を 1 通投げてから切るだけで、負けを引き分けに変えられてしまいます。
代償として、双方が正直でも片方にバグがあると**両方の画面が「あなたの勝ち」**になりえます。審判のいない P2P に大域的な整合を求めても答えは無いので、「2 つの勝ちは 2 つの引き分けより、動機の面で厳密に良い」と割り切りました。
制限時間は自分の時計で自分の手を決める
1 ターン 30 秒。時間切れになったら、自分で最小半径の円をランダムな位置に決めて commit します。相手からは普通の手と区別がつきません。「相手が時間切れだから相手の負け」と判定を相手に押し付けると、時計のずれで両者の認識が食い違うからです。
ちなみに 1 対戦の通信量
3 ターンの対戦で DataChannel を流れるのは両側合計 1,714 バイト(片側 9 通・857 バイト)でした。JSON でも何も困りません。
3.4 再戦:DataChannel の順序保証に乗る
対戦後に両者が [もう一度] を押すと、同じ接続の上で次の対戦が始まります。{"t":"rematch","turn":2} を 1 通送り、自分が送り、かつ相手からも受け取った時点で合意です(turn は他の種別と体裁を揃えるためだけのもので、受け取り側は検査しません)。
合意が成立する瞬間は両者で必ずずれ、どちらが先に対戦 2 に入るかは押した順と読み取りのタイミングで決まります。なので、こちらが対戦 2 の手を出す前に相手の対戦 2 の commit が届くのは普通のことです。メッセージに「何戦目か」の番号を持たせるべきか悩みましたが、DataChannel の既定は Ordered: true なので、
の順に必ず届きます(pion の CreateDataChannel(label, nil) も既定は Ordered: true)。シグナリングと違ってこちらは順序が保証されているので、世代番号は不要でした。
逆に、再戦のときに捨て忘れると静かに壊れる状態もあります。制限時間の起点です。捨て忘れると、対戦 2 のターン 0 の時計が対戦 1 の開始時刻から数えられ、始まった瞬間に時間切れになります。
実装には最初からこのリセットを入れていましたが、変異テストで確かめると、リセットを消しても 2 人対戦の検証用テストは緑のままでした。検証台の時計が止まったままだったので、時間に関する欠陥は原理的に見つからなかったのです。時計を外から渡せるようにし、対戦の間に 5 分進めるようにして、リセットを消すと落ちるテストになりました。
3.5 テストが「本当に落ちるか」を確かめる
このプロジェクトでは、テストを書いたら実装を意図的に壊して落ちることを確かめる(変異テスト)ことを徹底しています。WebRTC 周りでも、これで無力なテストを 1 件捕まえました。
candidate の緩衝を守るテストの最初の案は、「offer 側の candidate を、answer 側の SetRemote より先に届かせる」という片方向の待ちだけを入れていました。ところが緩衝を完全に取り去っても 3/3 で通りました。
原因は peer reflexive candidate です。answer 側が相手の candidate を 1 つも持っていなくても、offer 側から飛んできた接続確認要求の送信元アドレスから候補が「生えて」応答でき、結果的につながってしまうのです。ICE のこの頑健さがテストの穴になっていました。
両方向に待ちを入れて、双方とも相手の candidate を持たない状況を作ったところで、緩衝を外すと確実に落ちるテストになりました。
WebRTC の「つながるかどうか」のテストは、ICE が気を利かせて救ってしまうことがあります。守りたい性質を壊したら本当に落ちるか、必ず確かめましょう。
4. 諦めた点・割り切った点
4.1 TURN は入れない
一番大きな判断です。TURN が無いと、Symmetric NAT や一部の携帯回線ではつながりません(その割合は体感で 1 割前後という見込みで、実測ではありません)。
費用は障害ではありませんでした。 Cloudflare Realtime の TURN は $0.05/GB で、無料枠が毎月 1,000 GB(SFU と共有)あり、1 対戦が 1,714 バイトなら、接続確認などを多めに見て 1 対戦 50 KB としても 1 GB で約 2 万対戦です。
それでも入れないのは、持ち物が増えるからです。
- TURN の API トークンという秘密をサーバ(Lambda)に持たせる
- 短命の認証情報を発行する口をシグナリングに足す
- クライアントの ICE 設定を差し替える
- 普段は誰も通らない
relay経路が壊れていないことを確認し続ける
つながらない人を救う価値より、この 4 つを恒久的に抱える重さのほうが大きい、という判断です。そのかわり、30 秒でつながらなければ「接続できませんでした」と明示して CPU 対戦へ戻る導線を出します。「できないことを、できないと明示する」ほうが、このゲームの規模には合っていると考えました。
再検討するのは、失敗率が見込みより明らかに高いと実測で分かったときだけ、と決めています。
ちなみに 30 秒の打ち切りは「P2P の確立」にだけかけています。部屋を作って相手の参加を待つ段階にまでかけると、番号を口頭で伝えて相手が打ち込む間に切れてしまうので、こちらは 10 分にしています。
4.2 ブラウザの WebRTC API を直接叩かず、pion をそのまま使う
Go の WASM からは syscall/js でブラウザの RTCPeerConnection を直接呼べます。当初はそうするつもりでしたが、実際には pion/webrtc を GOOS=js GOARCH=wasm でビルドしてそのまま使っています(中身はブラウザの WebRTC API を呼びます)。
代償はサイズです。次の表は、オンライン対戦を入れた時点(2026 年 9 月)に測った値です。
| 項目 | サイズ |
|---|---|
| WASM(オンライン対戦を入れる前) | 14,805,815 B |
| WASM(入れた後) | 26,982,988 B(約 +12.18 MB) |
| └ うち pion | 約 10.05 MB |
| └ うち coder/websocket | 約 2.13 MB |
Brotli 圧縮後(brotli -q 11) |
4,239,886 B(約 4.24 MB) |
gzip 圧縮後(gzip -9) |
7,727,619 B |
ブラウザでは到達しない ICE・DTLS・TURN・SCTP・TLS 一式まで import 経由で焼き込まれ、リンクされるパッケージ数が 187 → 376 に増えました。-ldflags="-s -w" で削れるのは 630 KB 程度です。
syscall/js 版に書き換えれば約 10 MB 減らせる見込みですが、動いている実装を、検証していない削減見込みのために捨てる理由は無いと判断し、配信を Brotli の事前圧縮にすることで受け入れました。それでも、将来差し替えられるように rtc の外へ pion の型を一切漏らさない(SDP も candidate も自前の素朴な構造体に詰め替える)ようにしてあります。
gzip と Brotli でここまで差が出る(7.7 MB vs 4.2 MB)ので、Go の WASM を配るなら Brotli の事前圧縮はかなり効きます。
4.3 その他、やらなかったこと
- 再接続: 3 ターンしかないゲームなので、切断は対戦終了(切断側の負け)として扱います
- ランダムマッチング: 部屋番号のみ。キューの管理がまるごと不要になります
-
「断る」メッセージ: 再戦で
[やめる]を押すと接続を閉じるので、待っている側は DataChannel の close としてすぐ気づけます。通知を足しても見え方は変わらず、状態機械の分岐が増えるだけです - 部屋番号のクリップボードコピー: 番号は大きく表示されるので、読み上げで十分と判断して見送りました
5. 実測値まとめ
| 項目 | 値 |
|---|---|
| シグナリングの到着順 | 保たれない(20 通連続送信で毎回崩れる) |
| 欠落・重複 | 実測の範囲では無し(保証ではない) |
create → created(ウォーム) |
約 136 ms |
create → created(コールド) |
約 1.39 秒 → 「部屋を作成中…」表示が必須 |
Lambda の初期化(Init Duration) |
平均約 67 ms(これだけ見て「速い」と判断するのは誤り) |
| 1 対戦の DataChannel 通信量 | 両側合計 1,714 バイト |
| WASM サイズ(pion 込み、Brotli 後) | 約 4.24 MB(オンライン対戦を入れた時点)、現在は約 4.39 MB |
Lambda の初期化が 67 ms でも、エンドツーエンドではコールド時 1.4 秒かかりました。体感に対応する数字を測るのが大事です。
6. まとめ
- WebRTC 自体より、シグナリングの性質(順序・欠落・遅延)に振り回されました。サーバレスで組むなら到着順は仮定しない
- candidate の緩衝は必須。さらに、公開の経路から届く入力として上限・重複除去・古いほうから捨てるまで考える
- pion を使うなら、
OnMessageの登録タイミングとOnCloseの代入仕様に注意 - 審判のいない P2P では、**commit-reveal(nonce 必須)+盤面ハッシュ+「逃げたら負け」**で、技術的に防げないものを「得をしない」ようにする
- TURN を入れるかは、費用ではなく抱える持ち物で決めてもいい。入れないなら「つながらない」をはっきり伝える
- ICE は頑健すぎてテストの穴を作る。壊して落ちるかを確かめる
おまけ:Inkmaru の紹介
最後に、このゲーム自体の紹介をさせてください。
Inkmaru は、インクの濃さで陣地を奪い合う 1 対 1 のブラウザゲームです。
ルール
- 対戦は 3 ターン。毎ターン、両プレイヤーが同時に盤面へ円を 1 つ描きます
- 1 ターンに使えるインクの量は一定。大きく描けば薄く広く、小さく描けば濃く狭くなります
- 円が重なったところは濃いほうの陣地。濃さが同じなら中立(縞模様)
- 3 ターン後、陣地が広いほうの勝ち
大きな円で広く取るか、小さく濃い円で相手の陣地に打ち込むか。相手の手は見えないので、読み合いになります。
遊び方
- CPU 対戦: やさしい / ふつう / つよい の 3 段階
-
オンライン対戦: 部屋番号を相手に伝えて入ってもらうだけ。終わったら
[もう一度]で同じ相手と再戦でき、通算成績も出ます
インストール不要、ブラウザだけで遊べます。見た目は「墨と紙」をテーマに、色覚の多様性でも陣地の色が見分けられるよう、配色の色差を実測して選んでいます。
配信は S3 + CloudFront です。ブラウザ側のソースはこちらで公開しています。記事に出てきた緩衝(internal/rtc)や commit-reveal(internal/netplay)の実物も読めます。
ぜひ誰かと 1 戦遊んでみてください。つながらなかったら……TURN を入れなかった判断のせいです。そのときは CPU と遊んでもらえると嬉しいです。