ゲーム開発でウソスコアを防ぐ技術
はじめに
オンラインゲームでランキング機能を実装する際、最も頭を悩ませる問題の一つが不正なスコア登録による「ウソスコア問題」だと思います。
単純にクライアントからスコアを送信するだけでは、改ざんされたデータ送信を許すことになり、例えばオンラインランキングやレーティングの信頼性が損なわれてしまいます。
本記事では、Go言語の2DゲームライブラリEbitengineの代表的なサンプルゲーム「Flappy Gopher」を拡張し、不正スコアを効果的に防止したオンラインランキングシステムを実装した「Flappy Gopher With Ranking」プロジェクトについて解説します!
実際のデモはこちらで遊べます!!! 高得点を目指してみてください!
プレイ中画面 | NAME入力画面 | ランキング画面 |
---|---|---|
![]() |
![]() |
![]() |
不正スコアを防ぐ仕組み
簡易的な実装の問題点
オンラインランキングシステムを簡易的に実現しようとすると、まずは「ゲーム終了時にクライアントから最終スコアをサーバーに送信する方法」が浮かぶかと思います。
しかし、この方法ではパケットキャプチャやブラウザゲームであればChrome DevToolsのネットワークタブなどでAPIリクエストを知られ、そのリクエストを偽装して実際にゲームをプレイせずに高スコアを送信できてしまいます。
curl -X POST -d "{"name": "hacker", "score" : 5000000000000000}" https://online-ranking-system/score
Chrome DevToolsのネットワークタブ例
解決策:プレイ履歴の検証
ウソスコアの送信問題の解決策として、スコア自体でなくユーザの操作履歴を最後にサーバに送信させ、サーバ側で操作履歴からスコアを再計算し記録するという方法があります。
参考: https://vittorioromeo.info/index/blog/oh_secure_leaderboards.html
この方法はすべてのゲームで適用できるわけではなく、以下の三つの性質を有していることが条件だと作っていて感じました:
- 完全性: スコア計算に関連する全てのユーザ操作履歴を取得できる
- 一方向性: 操作履歴からスコアを算出することは簡単だが履歴からスコアは難しい
- 決定性: 同じ操作履歴から常に同じスコアが算出される
私はEbitengineというGoの2Dゲームエンジンが好きなのですが、そのEbitengineの代表的なサンプルゲームのFlappy Gopherはすべての性質を満たしていたので題材はこれに決めました!
-
Flappy Gopherの完全性
- 操作はクリックやタップによるジャンプのみ、どのタイミング(正確にはx座標)でジャンプしたかは簡単に記録できる、衝突時に確定・送信できる
-
Flappy Gopherの一方向性
- ジャンプ履歴からスコアを算出することは容易だが、逆に特定のスコアを出すためのジャンプ履歴を作成することは難しい
- gopherの直進をシミュレーションすればスコアが求まるが、逆にどうジャンプすればスコアが出せるのかはより複雑なシミュレーションが必要になる
-
Flappy Gopherの決定性
- 土管の隙間はプレイ開始時に与えられる乱数で決まるので、その乱数が固定されていればスコアはジャンプ履歴から一意に決まる
オンラインランキング機能追加のための変更点
元のコードは以下
1. プレイの開始
1-1. セッション管理とパイプ配置の決定
ゲーム開始時にクライアントからプレイ開始をRequestしてもらいます。
そのRequestに対してサーバはトークンと土管配置用の乱数シード(コード内ではPipeKey)を返します。
ここで土管配置用の乱数シードをクライアントとサーバ双方で共有することで、サーバ側でスコアを再計算することができるようになります:
// クライアント側のコード
func (g *Game) fetchToken() {
resp, err := http.Post(endpoint.JoinPath("api", "tokens").String(), "application/json", nil)
if err != nil {
log.Printf("Failed to get token: %v", err)
return
}
defer resp.Body.Close()
var result struct {
Token string `json:"token"`
PipeKey string `json:"pipeKey"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Printf("Failed to decode token response: %v", err)
return
}
g.token = result.Token
g.pipeKey = result.PipeKey
}
1-2. 開始時間の記録
このプレイ開始の際にサーバ側ではプレイ開始時間を記録しておきますプレイ終了時にも同様にプレイ終了時間を記録します。
クライアント側で開始終了時間をサーバに送信するのではなく、サーバ側に開始終了Requestが来た時にtime.Now()
でサーバ時間を記録しています。
簡単ですがこれでプレイ時間を偽装できなくなりますどうぶつの森で時間を巻き戻すみたいなことができなくなるイメージです:
-- RDBのTBL定義
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT(26) NOT NULL,
pipe_key TEXT(26) NOT NULL, -- 土管配置用の乱数シード
finished_at INTEGER NOT NULL, -- プレイ終了時間
created_at INTEGER NOT NULL -- プレイ開始時間
);
2. プレイ中 | ジャンプ履歴の記録
ゲームプレイ中、プレイヤーがジャンプするたびにその位置(X座標)をメモリ上に記録しておきます:
// クライアント側のコード
if g.isKeyJustPressed() {
// ジャンプ履歴をメモリに保存
g.jumpHistory = append(g.jumpHistory, g.obj.X16)
g.obj.Vy16 = -common.VyLimit
// ...
}
3. プレイの不正検知
3-1. サーバ側でのスコア再計算
ゲーム終了時、プレイヤー名とジャンプ履歴をサーバーに送信します。
サーバー側では、受け取ったジャンプ履歴を使ってゲームをシミュレーションし、スコアを再計算します:
// サーバ側のコード
func (u *ScoreUsecase) RegisterScore(ctx context.Context, token string, displayName string, jumpHistory []int) error {
// セッション情報を取得
session, err := u.repository.GetSessionByToken(ctx, token)
if err != nil {
return err
}
// パイプキーを使ってゲームをシミュレーション
obj := common.NewObject(common.InitialX16, common.InitialY16, 0, session.PipeKey)
// ジャンプ履歴を再現
for _, jumpX := range jumpHistory {
// 現在位置がジャンプ位置に達するまで進める
for obj.X16 < jumpX {
obj.X16 += common.DeltaX16
obj.Y16 += obj.Vy16
obj.Vy16 += common.DeltaVy16
if obj.Vy16 > common.VyLimit {
obj.Vy16 = common.VyLimit
}
// 衝突判定
if obj.Hit() {
return errors.New("invalid jump history: collision detected")
}
}
// ジャンプ
obj.Vy16 = -common.VyLimit
}
// 最終スコアを計算
score := obj.Score()
// スコアを保存
return u.repository.SaveScore(ctx, displayName, score)
}
3-2. クライアントに乱数シードを指定させない
スコア計算の際にクライアント側にはジャンプ履歴と合わせてトークンを送ってもらいます。
トークンはプレイ開始時に乱数シードと同時にサーバ側で生成しクライアントに送ったものです。
サーバ側ではこのトークンを元に乱数シードを特定し、スコアの再計算に使っています。
クライアント側に乱数シードを指定させないことで、特定の乱数シードで何度も練習して満を持して高スコアになる履歴を送信するといった不正を防いでいます:
// クライアント側のコード
func (g *Game) submitScore(playerName string) {
data := struct {
DisplayName string `json:"displayName"`
JumpHistory []int `json:"jumpHistory"`
}{
DisplayName: playerName,
JumpHistory: g.jumpHistory,
}
jsonData, err := json.Marshal(data)
if err != nil {
g.errorMessage = "Error preparing data"
log.Printf("Failed to marshal score data: %v", err)
return
}
// トークンをPathパラメータで指定
resp, err := http.Post(endpoint.JoinPath("api", "scores", g.token).String(), "application/json", bytes.NewBuffer(jsonData))
if err != nil {
g.errorMessage = "Network error"
log.Printf("Failed to submit score: %v", err)
return
}
defer resp.Body.Close()
// 省略
}
3-3. プレイ時間の検査
ジャンプ履歴送信とほぼ同じタイミングで2.でも書いた終了時間のサーバ側での記録も行っています
ここまでで記録した開始終了時間を元にプレイ時間が適切かどうかを確認します。
具体的には最遅、最速のFPS (frame per second)を30FPS〜60FPSと仮定し、ゲーム終了時のx座標から各FPSで何秒経っているはず、という秒数を計算し実際のプレイ時間がその時間に収まっているか確認します。
これにより何度も試行したりして時間をかけて高スコアになるジャンプ履歴を作成し、満を持して送信するという不正を防いでいます。
同様に不当にゲーム速度を落としてスローで遊んで高スコアを目指すということも防いでいます:
// サーバ側のコード
func (o *Object) IsValidTimeDiff(startTime, endTime time.Time) bool {
gameSec60FPS := o.X16 / DeltaX16 / 60
intervalSec60FPS := PipeIntervalX * TileSize * Unit / DeltaX16 / 60
// min: 60FPS, max: 30FPS+
minTime, maxTime := gameSec60FPS, (gameSec60FPS + intervalSec60FPS) * 2
diffSecond := int(endTime.Sub(startTime).Seconds())
return minTime <= diffSecond && diffSecond <= maxTime
}
技術スタック
syumai/workerのおかげで全部Goで安価に作れました!
フロントエンドもGoのEbitengineで作っているので物体の当たり判定や得点計算などのライブラリを使い回すことができています。
Goでフロントとバックエンド間でメソッドを使い回すのはかなりレアな気がしています...!
-
フロントエンド:
- Ebitengine (Go言語の2Dゲームライブラリ)
- WebAssemblyをCloudflare Pagesにホスティング
-
バックエンド:
- Cloudflare Pages Functions (Cloudflare PagesとWokersの自動接続)
- syumai/workers (Go言語でCloudflare Workersを開発するためのライブラリ)
- Cloudflare D1 (SQLiteベースのサーバーレスデータベース)
完全なソースコードは以下で公開しています!
まとめ
完全性、一方向性、決定性を満たしたゲームであれば単純なスコア改ざんだけでなく、ゲームプレイそのものを偽装する不正も効果的に防止できます。
もちろん、完全に不正を防ぐことは難しいですが、不正のハードルを大幅に上げることができます。
オンラインゲームでの不正防止は常に攻防の繰り返しですが、単純なスコア送信だけでなく、プレイ履歴の検証を組み込むことで、より信頼性の高いランキングシステムを構築できます
ぜひ皆さんのゲームプロジェクトにも取り入れてみてください!
Discussion