
サーバーレス構成でオンライン対戦ゲームを作ってみた
はじめに
オンラインゲームといえば、ゲームサーバーを常駐させて WebSocket で通信する構成が定石だと思います。
しかし、シンプルな状態遷移のゲームならば、サーバーが常駐している必要はないのではないかと、ふと思いました。
今回は、 Lambda + DynamoDB のサーバーレス構成で、どこまでオンライン対戦ゲームが実現できるかを検証してみました。
作るもの
こういう時は○×ゲームが定番ですが、それでは少し味気ないので、私の趣味の将棋を題材にしてみました。
とはいえ、9×9の本将棋を実装するちょっと大変なので、今回は 5×5 のミニ将棋を作成しました。Nintendo Switch の『世界のアソビ大全51』にも「5五将棋」として収録されているゲームです。
ブラウザ上で対戦できるものを作成します。画面はロビーと対局の 2 つで、ロビーで対局を作成し、対戦相手の参加を待ちます。対戦相手がロビーから対局に参加すると、先手・後手がランダムに決まって対局が始まるようになっています。

ロビーの画面

対局開始
今回作成したコードは GitHub で公開しているので、興味のある方はご確認ください。
機能要件
ミニ将棋をオンラインで遊ぶためには、以下の要件が必要になります。
- ルール通りに動く
- ① 反則手がエラーになる
- ② 同時アクションを排他制御する
- 対戦が成立する
- ③ 相手と対局を開始できる
- ④ 対局者以外は操作できない
- ⑤ 持ち時間が切れると対戦が終了する
- 通信が成立する
- ⑥ お互いの指し手がすぐに反映される
- ⑦ 再接続で対戦に復帰できる
以降はそれぞれの要件について、実際に作ってみた結果を書いていきます。
構成
構成は以下です。

Lambda ではブラウザからの WebSocket の接続を受けて持ち続けることができないので、ブラウザとの接続は API Gateway の WebSocket API で実装しました。
API Gateway は、メッセージを受け取るたびにルートに対応する Lambda を呼び出します。
Lambda からブラウザへ送るときは、API Gateway の @connections API(PostToConnection)に接続 ID を渡します。
ブラウザは {"action": "move", ...} のような JSON を送り、API Gateway は action の値で呼ぶ Lambda を選びます。
| ルート | 内容 |
|---|---|
$connect |
接続を張るときに呼ばれる。保存済みの ID を照合する |
$disconnect |
接続が閉じた後に呼ばれる |
hello |
ID を発番する |
listGames |
参加待ちの対局の一覧を返す |
createGame・joinGame |
対局を作る・参加する。2 人がそろったら先手・後手をランダムに決める |
move・resign・claimTimeout |
指し手・投了・時間切れの申告 |
sync |
局面を同期する |
ping |
接続を保つための通信 |
サーバーからブラウザへは、welcome(発番した ID)・game(対局の状態)・lobby(参加待ちの対局の一覧)・error(反則などのエラー)の 4 種類だけを送ります。
DynamoDB は 1 つのテーブルに 4 種類のアイテムを置きました。
| アイテム | キー | 中身 |
|---|---|---|
| ユーザー | USER#<userId> |
秘密の値のハッシュ |
| 対局 | GAME#<gameId> / META |
盤面・持ち駒・手番・手数(version)・棋譜・持ち時間・作った人と先手・後手の userId・参加待ちの印(lobby) |
| 対局ごとの接続 | GAME#<gameId> / CONN#<connectionId> |
局面を送る相手を Query で取るため |
| 接続 ID ごとの接続 | CONN#<connectionId> / META |
接続から userId と対局を引くため |
棋譜も対局のアイテムに入れて、1 回の指し手で書き換えるアイテムを 1 つにしました。この理由は要件②で書きます。
検証環境
- AWS のリージョン: ap-northeast-1(東京)
- Lambda: Node.js 24(
nodejs24.x)・x86_64。メモリは 128 MB と 1024 MB で比べ、それ以外の計測は 1024 MB - AWS CDK(aws-cdk-lib 2.275.0)
- 計測は手元の Mac から行った
要件①:反則手がエラーになる
Lambda が指し手のたびに DynamoDB から局面を取得して判定します。反則のときは理由(nifu など)を返し、文面を画面に表示します。
局面は、将棋で一般的な SFEN という記法(初期局面は rbsgk/4p/5/P4/KGSBR b -)で記述するようにしました。千日手の判定ではこの文字列を使っています。

先手の画面。5 筋に歩があるのに持ち駒の歩を打とうとして、二歩のエラーが出ている
要件②:同時のアクションを排他制御する
常駐のゲームサーバーなら、ゲームの状態を 1 つのプロセスがメモリに持ち、届いた指し手を 1 つずつ順に処理するだけで、この制御は簡単にできます。
一方 Lambda はステートレスな処理をする仕組みなので、呼び出しをまたいでメモリに状態を持つことができません。
今回の実装では、ゲームの状態は DynamoDB に置き、呼び出しのたびに取得・更新します。メッセージが 2 通同時に届くと、API Gateway は Lambda を 2 つ並行して呼び、2 つの Lambda はメモリを共有しないまま「読む → 確かめる → 書く」をそれぞれ行います。そのため、どちらも同じ局面を読み、どちらも「合法」と判断し、どちらも書くことがあります。二度押しなら、先手が 2 手続けて指した状態になります。
これを防ぐため、DynamoDB への書き込み処理に楽観的排他制御を実装しました。
対局のアイテムに手数(version)を持たせ、「version が取得時と同じ」を条件にデータを更新します。
export async function saveGame(game: GameItem, expected: { version: number; status: GameStatus }): Promise<boolean> {
try {
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: { ...keys.game(game.gameId), ...game },
ConditionExpression: "#version = :version AND #status = :status",
ExpressionAttributeNames: { "#version": "version", "#status": "status" },
ExpressionAttributeValues: { ":version": expected.version, ":status": expected.status },
}),
);
return true;
} catch (error) {
if (isConditionalCheckFailed(error)) {
return false;
}
throw error;
}
}
先手が同じ手を 2 通送った場合の流れです(対局は version = 4・先手番)。
| 順 | 1 通目の Lambda | 2 通目の Lambda | 対局のアイテム |
|---|---|---|---|
| 1 | 読む(version = 4) |
読む(version = 4) |
version = 4 |
| 2 | 条件 version = 4 で書く → 成功 |
version = 5・後手番 |
|
| 3 | 条件 version = 4 で書く → 失敗 |
version = 5 のまま |
|
| 4 | 両者に新しい局面を送る | 先手に「局面が古い」と返す |
画面は、自分が見ている局面の version も指し手に付けて送り、Lambda は書く前に、読んだ version と同じかを確かめます。2 通目が通信の再送で遅れて、その間に相手が指していた場合に、先手が見ていない局面で 2 通目が通ってしまうのを防ぐためです。
棋譜を 1 手ずつ別のアイテムにすると、対局と棋譜の 2 つを一緒に書くためにトランザクション(TransactWriteItems)が必要になります。トランザクションの書き込みは通常の 2 倍の単位を消費するので、棋譜は対局のアイテムに入れ、条件付きの書き込み 1 回で済むようにしました。
画面でも、指し手を送ってから応答が届くまでは盤の操作を受け付けないようにしています。ただしサーバーは画面を信用せず、条件付き書き込みで弾きます。
同時に 2 通送ってみる
スクリプトでアクションを 2 通連続で送ってみました。
- 先手が同じ指し手を 2 通送る
- 相手の手番で、相手と同時にアクションを送る
それぞれ 100 回行い、最後に対局を読んで、手数が 200(100 回 × 2 手)で、棋譜がスクリプトの送った手順と一致するかを確かめました。
| 場合 | 画面に返ったエラー | 書くときの条件で弾かれた回数 | 最後の手数 | 棋譜 |
|---|---|---|---|---|
| 二度押し | 局面が古い 48・手番ではない 52 | 48 | 200 | 一致 |
| 相手の手番 | 手番ではない 94・局面が古い 6 | 0 | 200 | 一致 |
どちらの場合も、通ったのは毎回 1 通だけでした。二度押しの 48 回は楽観的排他制御でエラーになっています。残りの 52 回は、2 つ目の Lambda が 1 つ目の書き込みの後に読んだので、手番の確認で弾かれました。相手の手番の送信は、すべて書く前の確認で弾かれています。
要件③:相手と対局を開始できる
対局一覧は DynamoDB の GSI をスパースインデックスとして利用して実装しました。
スパースインデックスとは、GSI のキーにした属性を一部のアイテムだけに持たせる使い方です。DynamoDB は、GSI のキーの属性を持たないアイテムを GSI にコピーしないので、GSI を Query することでキーの属性を持つアイテムだけを取得できます。テーブル全体を Scan して絞り込む方法と比べて処理を軽くすることが可能です。
今回は、GSI の PK を lobby、SK を作成時刻にしています。対局のアイテムは、参加待ちの間だけ lobby = "waiting" を持ち、相手が参加したら lobby を削除します。こうすることで、前述したスパースインデックスの仕組みで、参加待ちの対局だけをコピーした GSI を作成できます。 listGames は、この GSI を Query して、10 分以内に作成された参加待ちの対局を新しい順に 20 件まで取得します。
2 人目のプレイヤーが参加した時点で、先手・後手をランダムに抽選します(crypto.randomInt(2))。参加の Lambda は、抽選の結果を senteUserId・goteUserId に書き、対局の状態(status)を waiting から playing に更新し、lobby を削除して対局を始めます。この更新は「参加待ちで、作成者本人ではない」を条件にしているので、2 人目の参加で状態が playing に変わった後は、ほかの人は参加できません。
UpdateExpression:
"SET senteUserId = :sente, goteUserId = :gote, #status = :playing, clock = :clock REMOVE lobby",
ConditionExpression: "#status = :waiting AND hostUserId <> :me",
要件④:対局者以外は操作できない
ユーザー登録は作らず、画面を初めて開いたときに、サーバーが ID(u-91a2f0 など)と秘密の値を発番します。画面はこの 2 つをブラウザに保存し、次からは接続の URL に付けて送ります。$connect の Lambda がハッシュと照合し、合わなければ 401 を返して接続させません。
接続した後のメッセージでは、画面が送った userId は使わず、接続 ID から引いた userId で「手番の対局者か」を確かめます。参加できるのは 1 人だけで、2 人目以降の参加は、要件③の条件付きの書き換えで弾かれます。
3 つ目の画面(別の ID)から、2 人で対局中の対局に参加・sync・指し手・投了を送ると、すべてエラーで弾かれ、対局は変わりませんでした。先手・後手をランダムにした後にも、対局を 10 回作って確かめ、3 人目の参加は 10 回とも弾かれました。秘密の値を書き換えた接続は、curl で接続の要求を送ると 401 が返りました。なお、ブラウザの WebSocket は 401 を画面のコードに渡さず、code 1006 で閉じたことしか分かりません。そこで画面は、開く前に閉じることが 3 回続いたら「ID を作り直す」ボタンを出すようにしました。
要件⑤:持ち時間が切れると対戦が終了する
Lambda は呼ばれたときしか動かないので、サーバーでタイマーを動かし続けることはできません。そこで、対局のアイテムに「それまでに消費した時間(usedMs)」と「今の手番が始まった時刻(turnStartedAt)」を持たせ、指し手が届いたときに残りの持ち時間を計算するようにしました。
function usedByTurnSide(clock: Clock, turn: Side, now: number): number {
return clock.usedMs[turn] + Math.max(0, now - clock.turnStartedAt);
}
// 消費時間 > 持ち時間 で時間切れ
export function isTimeUp(clock: Clock, turn: Side, now: number): boolean {
return usedByTurnSide(clock, turn, now) > clock.timeLimitMs;
}
| 時刻 | 起きること | 先手の消費 | 後手の消費 | 手番の開始時刻 |
|---|---|---|---|---|
| 10:00:00.000 | 後手が参加 | 0 | 0 | 10:00:00.000 |
| 10:00:12.000 | 先手が指す | 12000 | 0 | 10:00:12.000 |
| 10:00:47.500 | 後手が指す | 12000 | 35500 | 10:00:47.500 |
| 10:01:20.000 | 先手が指す | 44500 | 35500 | 10:01:20.000 |
また、画面のタイマーが 0 になったら、その画面が claimTimeout を送り、サーバーが自分の時刻で確かめて終局にするようにしています。
以下は対局を放置して確かめた結果です。
| 時刻 | 起きたこと |
|---|---|
| 15:52:54.810 | 先手の時間切れの時刻 |
| 15:52:55.435 | 先手の画面が claimTimeout を送った |
| 15:52:56.897 | サーバーが時間切れと判定した(claim-timeout の Lambda のコールドスタートで 1.5 秒かかった) |
| 15:52:57.425 | 両方の画面に「後手の勝ち(時間切れ)」が届いた |
2 つの画面は、どちらもサーバーとの時計の差を −220 ミリ秒と求め、同じ残り時間を表示していました。
要件⑥:お互いの指し手がすぐに反映される
先手が指したとき、move の Lambda は次の順で動きます。
- 接続 ID から
userIdを引く - 対局のアイテムを読み、手番の対局者か・持ち時間が残っているか・反則でないかを確かめる
- 対局のアイテムを、条件付きで書き換える
- 対局に結び付いた接続(先手と後手の 2 つ)を Query で取り、両方に新しい局面を送る
4 の送信の部分です。閉じていた接続に送ると 410 GoneException が返り、接続のアイテムが削除されます。
// 対局に参加している接続すべてへ送る。閉じていた接続のアイテムは消す($disconnect が届かなかった場合の後片付け)。
export async function sendToGame(gameId: string, message: ServerMessage): Promise<void> {
const { Items = [] } = await ddb.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
ExpressionAttributeValues: { ":pk": keys.game(gameId).pk, ":prefix": CONNECTION_PREFIX },
}),
);
await Promise.all(
Items.map(async (item) => {
const connectionId = item.connectionId as string;
if (await send(connectionId, message)) {
return;
}
console.log(JSON.stringify({ event: "gone", gameId, connectionId }));
await Promise.all([
ddb.send(new DeleteCommand({ TableName: TABLE_NAME, Key: keys.gameConnection(gameId, connectionId) })),
ddb.send(new DeleteCommand({ TableName: TABLE_NAME, Key: keys.connection(connectionId) })),
]);
}),
);
}
反映までの時間
Node.js のスクリプトから WebSocket の接続を 2 つ作成し、合法手を交互に 51 手指して、指してから相手の接続に新しい局面が反映されるまでの時間を測りました。Lambda のメモリは 128 MB と 1024 MB で比べました。
| メモリ | 1 手目(コールドスタート) | 2〜51 手目の中央値 | p95 | 最大 |
|---|---|---|---|---|
| 128 MB | 2035 ミリ秒 | 245 ミリ秒 | 529 ミリ秒 | 1527 ミリ秒 |
| 1024 MB | 607 ミリ秒 | 70 ミリ秒 | 108 ミリ秒 | 186 ミリ秒 |
1 手目はコールドスタートしています。
1024 MB なら、2 手目以降は 0.1 秒以内に相手の画面へ反映されました。
レイテンシーの差がどこから来たかを、move の Lambda のログ(REPORT の行)で確認しました。
| メモリ | 初期化(Init Duration) |
1 手目の処理 | 2 手目以降の処理の中央値 |
|---|---|---|---|
| 128 MB | 314 ミリ秒 | 1622 ミリ秒 | 272 ミリ秒 |
| 1024 MB | 259 ミリ秒 | 197 ミリ秒 | 37 ミリ秒 |
コールドスタートの初期化の時間は、メモリを増やしてもほとんど変わりませんでした。差が大きいのは、処理(DynamoDB の読み書きと 2 つの接続への送信)の時間です。
Lambda はメモリに比例して CPU を割り当てるので、128 MB では処理そのものが遅くなっていました。1024 MB の 2 手目以降は、届くまでの 70 ミリ秒のうち、Lambda の処理が 37 ミリ秒で、残りの約 33 ミリ秒が、手元から API Gateway までの往復と API Gateway の処理です。
なお 128 MB では、4 手目でもう一度コールドスタートが起き、1527 ミリ秒かかりました。前の手の Lambda が処理を終える前に次の手が届き、別の実行環境が立ち上がったのではないかと推測しています。
要件⑦:再接続で対戦に復帰できる
局面の状態を DynamoDB に置き、送信は「局面が変わった」ことを知らせるためだけに使います。送れなかった相手がいても、書き込みは取り消しません。画面は開き直したときに sync を送って、今の局面を同期します。後手の画面を閉じている間に先手が指し、後手の画面を開き直すと、同じ ID の後手として、先手の指した手を含む局面に戻りました。
一方、AWS のドキュメントによると、WebSocket API の接続には次の制限があります。
| 制限 | 値 | この対局での扱い |
|---|---|---|
| 接続の長さ | 最長 2 時間 | 1 局は持ち時間 10 分ずつで最長 20 分。切れたら画面が自動で接続し直し、sync する |
| 無通信で切れるまで | 10 分 | 画面から 5 分ごとに ping を送る |
$disconnect |
届く保証は無い | 送信の 410 と、DynamoDB の TTL(接続から 3 時間後)で接続のアイテムを消す |
無通信で切れるまでの時間を、スクリプトで 13 分測りました。
| 接続 | 結果 |
|---|---|
| 何も送らない | 最後に送ってから 10.00 分で切られた |
5 分ごとに ping を送る |
13 分たっても切れなかった |
| サーバーから送られるだけ(相手が 4 分後に指し、8 分後に投了) | 13 分たっても切れなかった |
サーバーからの送信でも、無通信の時間は数え直しになりました。ただ、片方が手番のまま 10 分近く考えている間は、どちらの画面も送らず、受け取りもしません。そのため、5 分ごとの ping は要ります。ping は Lambda を呼ばないモック統合(API Gateway だけで応答を返す設定)で受けるので、Lambda の費用はかかりません。
$disconnect が届かない場合の後片付けも確かめようとしましたが、後手の接続を張ったスクリプトのプロセスを強制終了(SIGKILL)しても、API Gateway は 20 ミリ秒前後で切断を検知して $disconnect を呼びました。そこで、$disconnect で消えた接続のアイテムをテーブルに書き戻して「届かずに残った」状態を作り、相手が指したところ、その接続への送信が 410 になり、アイテムが消えました。Wi-Fi が切れた場合のように、切断を検知できない場面での動きは確かめていません。
コスト
1 局(2 人で 60 手・20 分)の費用を見積もりました。操作の回数は、51 手の計測の CloudWatch の値と、ハンドラのコードから数えました。DynamoDB の読み書きの単位は、コードから組み立てた数え方で 51 手を数えると、実測(読み込み 120.5・書き込み 200)とほぼ一致しました。単価は東京リージョンの値(AWS の Price List API で取得)で、無料枠は入れていません。比較のため、画面が 1 秒ごとにポーリングする構成の試算も並べます。
| 項目 | WebSocket(今回の構成) | 1 秒ごとのポーリング(試算) |
|---|---|---|
| API Gateway | メッセージ 201 通・接続 40 分: $0.000266 | リクエスト 2460 件: $0.003173 |
| Lambda | 68 回・6.5 GB 秒: $0.000122 | 2468 回・116.4 GB 秒: $0.002434 |
| DynamoDB | 書き込み 255・読み込み 147.5 単位: $0.000203 | 書き込み 255・読み込み 1347.5 単位: $0.000374 |
| 合計 | 約 $0.00059 | 約 $0.0060 |
1 局およそ $0.0006 で、ポーリングの試算の約 10 分の 1 でした。WebSocket の構成の内訳で大きいのは、メッセージ(43%)と DynamoDB の書き込み(31%)です。指すたびに対局のアイテムを丸ごと置き換えているので、手数が進んでアイテムが大きくなるほど、1 手あたりの書き込みの単位が増えます(51 手で 5.4 KB、1 手で 6 単位)。
作っていて詰まったところ
- 双方向の通信をどう保つか
オンライン対戦では、相手のアクションを自分の画面へ反映する必要があるので、ブラウザとサーバーの間で双方向の通信を保ち続けなければなりません。Lambda だけでは接続を持ち続けられないので、どう実装するかで迷いました。今回の検証で API Gateway に WebSocket API があることを知り、Lambda でのオンラインゲームの実装ができました。 pingのモック統合がInternal server errorを返した
CDK のドキュメントの例をそのまま使ったところ、テンプレートを選ぶ式の値($default)に合うキーのテンプレートが無く、pingのたびにエラーが返っていました。AWS のチュートリアルと同じく、式とキーの両方を200にして直りました- バックグラウンドタブでタイマーが止まった
画面のタイマーをrequestAnimationFrameで回していたので、タブがバックグラウンドになる止まり、claimTimeoutが送られなくなりました。setIntervalに変えて対応しましたが、時間切れの確認では、バックグラウンドのタブの画面はclaimTimeoutを送りませんでした。 - エラーがすぐ消えた
サーバーは、不正なアクションにエラーを返した直後に今の局面も送ります。画面が局面を受け取るたびにエラーの表示を消していたので、二歩のエラーが一瞬で消えていました。局面が進んだときだけ消すようにしました
検証結果
機能要件の 7 つは、常駐するサーバーなしで実装でき、実測でも確かめられました。Lambda で実装する上で困る点は、ゲームの状態を DynamoDB に置き、API Gateway で WebSocket 通信をすることである程度解消することができました。
一方で、次のような限界もあります。
- API Gateway の WebSocket 接続は最長 2 時間なので、長い対局や長い待ち時間は、接続し直すことが前提になる
$disconnectは届く保証が無いので、接続のアイテムが一時的に残る可能性がある- 時間切れの判定はブラウザからの
claimTimeoutに依存している。両方のプレイヤーがブラウザを閉じると、次にどちらかがアクセスするまで「対局中」のままになる - 秘密の値を URL に付けて送信するので、API Gateway のアクセスログを有効にすると URL ごと記録される
- 対局のアイテムが手数とともに大きくなる(200 手で 19 KB)。長い対局を扱うなら、棋譜を別のアイテムに分ける必要がある
- 観戦・マッチング対戦・ユーザー登録は作っていないし、難易度が高そう
- 参加待ちの一覧は GSI から取得するので結果整合性になる
まとめ
常駐のゲームサーバーを持たないサーバーレス構成(Lambda + API Gateway + DynamoDB)であっても、簡単なオンライン対戦ゲームを実装することができました。
もちろん限界はありますが、工夫でなんとかなる内容も多くあったのが印象的でした。
オンライン対戦だからと常駐サーバーを採用するのではなく、必要な要件を吟味した上で最適なアーキテクチャを選択することが大切だと実感しました。
コード: https://github.com/ishiguro-taisei/minishogi-serverless











