ギリギリオンライン — タップゲームをオンライン対戦化する MVP
| 開発記録 | ギリギリジャンパー
タグ: #Flutter #ゲーム #MVP
「激ムズ!! ギリギリジャンパー」をオンライン対戦にしたい。そう思って最初に作ったのは、オフラインの CPU 対戦版でした。通信レイヤーはゼロ。ゲームロジックを純粋関数に切り出して、対戦の体験だけを先に固めます。MVP の切り方と、そこからオンライン化へ向かう道筋の記録です。
まずオンライン対戦にしたかった
単体プレイで完結していた「激ムズ!! ギリギリジャンパー」を、リアルタイムのオンライン対戦にしたい。ずっとそう思っていました。
けれど、最初からマッチングもサーバーも通信も作るのは、さすがにリスクが高すぎます。全部いっぺんに手を出すと、たいてい途中で止まります(何度かやりました)。
そこで MVP として「オフライン CPU 対戦版」を先に作ることにしました。ゲームバランス・操作感・UI を固めるのが狙いです。
MVP の範囲
作ったのはこれだけです。
- 1 人 vs CPU の対戦モード
- スコアの比較、勝敗判定
- 対戦中の UI(自分の画面と相手の画面を同時表示)
- CPU の AI(複数の難易度)
通信レイヤーは一切作っていません。すべてローカル完結です。「対戦の体験」だけを先に固めます。
既存ゲームコードの分離
元のゲームはシングルプレイ前提の設計でした。なのでゲームロジックを純粋関数として切り出す作業から始めます。
- 入力(タップ)
- ゲーム状態(プレイヤー位置、障害物、スコア)
- 出力(次の状態)
この 3 つを明確に分けました。すると CPU の AI も「ゲーム状態を見て、次のタップタイミングを返す関数」として書けます。
純粋関数にしておくと、状態は GameState、入力は PlayerInput と型で固定できます。テストもリプレイも通信化も、足場はここです。
/// ステートレスなゲーム遷移関数。
GameState advance(GameState state, PlayerInput input, double dt) {
if (state.isGameOver) return state;
final nextPlayer = _movePlayer(state.player, input, dt);
final nextObstacles = state.obstacles
.map((o) => o.shifted(dt))
.where((o) => !o.isOutOfScreen)
.toList(growable: false);
final hit = nextObstacles.any((o) => o.overlaps(nextPlayer.hitbox));
return state.copyWith(
player: nextPlayer,
obstacles: nextObstacles,
score: hit ? state.score : state.score + 1,
isGameOver: hit,
);
}
CPU AI の作り方
CPU は「障害物のバーが近づいてきたタイミングでジャンプする」だけのアルゴリズムです。
- 弱い AI: 反応が遅い、ジャンプタイミングが甘い
- 中 AI: 標準的なタイミング
- 強い AI: ギリギリでジャンプする(コンボボーナス狙い)
プレイヤーが弱 AI に勝てる難易度から始めます。そこから徐々に強くなるよう調整しました。
画面構成
対戦画面は上下分割です。上が自分、下が CPU。両者の同時進行が見えるレイアウトにしました。
オンライン化への道筋
MVP の体験が固まったら、次は通信レイヤーの追加です。
- ローカル状態の更新を「イベント」に分解
- イベントをサーバー経由でブロードキャスト
- クライアント間で状態を同期
ゲーム状態は基本的に「タップイベント」のタイムスタンプで決まります。だから通信量は少なく済むはずです。リアルタイム対戦の本当のハマりどころは予測補正と遅延処理。そこは MVP の次のフェーズでやります。
まとめ
- オンライン化は「対戦体験」を先に固めてから通信を足す
- ゲームロジックは純粋関数にするとテストもしやすい
- CPU は AI 難易度の段階を最初から用意する
MVP を切ると、「先にやらなくていいこと」がはっきりします。作る判断より、作らない判断のほうが難しい。