はじめに

皆さんこんにちは!初めまして、こたと申します。この記事ではCA Tech JOB生として一ヶ月間ゲームのバックエンド開発で働いてみて学んだことを共有したいと思います!

インターン前の目標

今回のインターンでは以下の2つを経験・学ぶことを目標として参加しました。

チームで開発する

これまでは個人開発が中心だったため、多くの人が協力して一つの製品を作り上げる現場の経験を積みたいという思いがありました。特に個人開発では、仕様の決定、開発、運用、さらには利用に至るまですべてを一人で担うため、仕様の自由な決定や変更、機能の追加・削除が容易です。しかし、複数人が関わる製品開発ではそうはいきません。そのため、他者と密接にコミュニケーションを取り、合意形成を図りながら仕様に沿って開発を進め、方針変更があれば再度合意形成する、というプロセスを経て製品を作り上げていく経験を積みたいと思っていました。

大規模な開発・運用環境を経験する

ソフトウェア開発において、個人的に最も難易度が高いと感じているのは、高トラフィックな環境下でサービスを安定的に提供することです。個人開発の規模では、ユーザー数は数十人程度で、インフラも手元のPCで十分賄えます。しかし、世の中の多くの製品は、数万人・数十万人規模のユーザーが日常的に利用するもので、そうした大規模サービスを支えるためのノウハウを習得したいという思いがありました。また、大規模サービスの運用経験だけでなく、多くの開発者が長年にわたって積み上げてきた、大規模なコードベースでの開発経験も積みたいと考えていました。

やったこと

特定のイベントをクリアした人のスコアを元に順位を決める、ランキング機能を作成しました。

技術的にはRedisのSorted Setsという、自動的にスコア順にソートしてくれるデータ構造を使用しています。

RedisのSorted SetsがScoreを降順に並び替えてランキングを作っている様子

 

以下はGoで書く場合の簡単な例ですが、ご覧のようにとても簡単にランキングを作ることができます。

 

go

key := "ranking"

member := "12345"

score := 777




// スコアの追加

rdb.ZAdd(ctx, key, redis.Z{Member: member, Score: score}).Result()




// 自分の順位を取得

rank, err := rdb.ZRevRank(ctx, key, member).Result()




// 0から順位換算されるので +1 して出力する

fmt.Println("自分の順位: ", rank+1)

 

ただし、RedisのSorted Setsは同一スコアの場合はMemberの辞書順でソートされるという性質を持つので、このままだと「同一スコアの場合は先にそのスコアを獲得した人の勝ちにする」というゲームではよくある要件を実現できないという問題があります。先ほどの図ですと “Royce” と “Norem” が同じスコアなので辞書順の降順に並び替えると必ず ”Royce” は “Norem” より前になってしまいます。

そのため対策としてタイムスタンプをScoreの末尾に付与しました。Redisのスコアの型は64bitの浮動小数点で、-9007199254740992 から 9007199254740992 の値が表現可能なため、最大値まで表現できる15桁分使えることになります。

さらにスコアの降順でランキングを作ると、タイムスタンプを単純に付与するだけだと後に同じスコアを達成した人の方が末尾が大きくなってしまいます。

例えばAさんが時刻100の時にスコア777を取ったら 777100 となります。Bさんが時刻200の時にスコア777を取ったら 777200 となります。これを降順にするとAさんよりBさんが後にスコアを達成したのにも関わらずBさんが順位が上になります。ですので今回はイベント終了時刻からスコアを獲得した時刻を引くことで対応しています。これにより終了時刻が500の場合はAさんは500 – 100 = 400 で 777400 となり、Bさんは 500 – 200 = 300 で 777300 になるのでAさんの順位が上になります。

単純にタイムスタンプを付与するだけでは順位が逆転してしまう

 

これらの処理をGoで書くとこんな感じになります。

 

go

// スコアとタイムスタンプからscoreWithTimestampを計算

func CalculateScoreWithTimestamp(score int64, now, end time.Time) (int64, error) {

diff := end.Sub(now)




// 上位8桁がスコア、下位7桁がタイムスタンプ

// スコアは桁数が不定、タイムスタンプは必ず7桁になるように先頭を0埋めする

scoreWithTimestamp := fmt.Sprintf("%d%07d", score, int64(diff.Seconds()))

result, err := strconv.ParseInt(scoreWithTimestamp, 10, 64)

return result, nil

}




// scoreWithTimestampとendからスコアとnowを復元

func ExtractScoreAndNow(scoreWithTimestamp int64, end time.Time) (score int64, now time.Time, err error) {

scoreWithTimestampStr := strconv.FormatInt(scoreWithTimestamp, 10)




// 下位7桁がタイムスタンプ、それより上位がスコア

timestampStr := scoreWithTimestampStr[len(scoreWithTimestampStr)-7:]

scoreStr := scoreWithTimestampStr[:len(scoreWithTimestampStr)-7]




// パース処理

timeDiffSeconds, err := strconv.ParseInt(timestampStr, 10, 64)

score, err := strconv.ParseInt(scoreStr, 10, 64)




// イベント終了時刻からタイムスタンプを引いた時刻がクリア時刻となる

now = end.Add(-time.Duration(timeDiffSeconds) * time.Second)




return score, now, nil

}




func main() {

score := int64(777)

now := time.Date(2025, 9, 16, 12, 00, 00, 0, time.UTC)

end := time.Date(2025, 9, 30, 11, 59, 59, 0, time.UTC)




scoreWithTimestamp, err := CalculateScoreWithTimestamp(score, now, end)

fmt.Println(scoreWithTimestamp) // 7771209599




extractedScore, extractedNow, err := ExtractScoreAndNow(scoreWithTimestamp, end)

fmt.Println(extractedScore) // 777

fmt.Println(extractedNow)   // 2025-09-16 12:00:00 +0000 UTC

}

 

この例ですと、スコアとタイムスタンプを結合したものは 7771209599 となり、スコア 777 の末尾にタイムスタンプ7桁の 1209599 がくっ付いているのがわかると思います。

今回はUNIX時間で処理を行うことにしたのでタイムスタンプ部分の桁数は7桁になりました。ただし時刻の差が大きくなりすぎると桁数が増えるので、注意が必要です。業務ではプランナーさんとも相談して桁数が7桁を越えるような長時間のイベントにならないかなどを確認してこの方式に決定しました。他にもUNIX時間を秒ではなくミリ秒にするとさらに高精度になるが、3桁分圧迫されることになるので将来的にスコアがインフレすると収まりきらなくなる可能性があるのでこの仕様で大丈夫かなどの確認も行っていました。

 

さらに厄介なことにランキングにはBotが混入してきます。リアルタイムで弾くことができれば一番良いのですが、当初の方針ではリアルタイムは難しく日次でバッチ処理を行うことでBotを排除する計画になっていました。しかしそれだとBotが排除されたタイミングでランキングが大きく変動することになり信頼性が無くなると考えました。

そこでランキングAとBの2つを用いて管理する案を提案しました。ゲームクリア時に直接登録されるのはAで、これはユーザーとBotが混じったランキングになっています。それを1時間に1回の間隔でバッチ処理を行い、Botを排除したクリーンなランキングデータBを用意します。このBだけをユーザーに見せることでBotの影響を受けないランキングを提供できるのではないかと思い、プランナーさんとも相談をしてこの仕様に決定しました。

Bot対策のためのランキングA/B構成案

 

この構成を元に実装を行いPRを出しました。そこでレビュアーの方から指摘を受けました。「この構成怖くない?」と。

というのもBotの量次第ではあるのですが、バッチで大量に削除コマンドを発行するのは計算量的に削除する個数がMだとすると O(M log(N)) かかります。さらにAでBotをフィルタリング後にBに複製する構成で組んでいたんですが、これもNが全体の個数だとすると O(N log(N)) かかります。これを1時間に1回のペースでやろうとしていました😱

これはマズいと判断し、急遽サーバーエンジニアの皆さんに集まってもらい、課題を説明し解決策を議論しました。

その結果、別の部分で使用していたロジックが実はBot対策にも使えるのではという話が出て、調査してみたところ99%ほどBotを弾くことができたため

無事Botの影響が少ないランキングの集計 & リアルタイム表示 & Redisを1台だけで運用することができ、事なきを得ました。

学んだこと

方針変更時の技術評価の重要性

Botの件から方針を変更し前提が変われば、新しい方針が技術的に可能かどうか、運用負荷が高くないか、負債にならないかなど再評価する必要があると深く感じました。ユーザーがいてたくさんの人が関わり実際に動く製品なので「作って終わり」ではなくその先も見据えて設計・開発・運用する必要があると思いました。

大規模な開発環境での立ち回り方

既存の大規模なコードベースに初めて触れた際は、正直、最初の認知負荷はかなり高かったのですが、AIも活用しながら自分が担当する機能に関連する部分を見つけ出し、コードを読み解きながら全体の処理の流れを理解していく過程は、非常に大きな学びとなりました。小規模な開発であればあっという間に終わるような修正作業でも、大規模なコードベースで多数の開発サーバーが立ち上がる環境では、目的の処理フローを正確に追う必要があり、時間を要しましたが、そのフローを辿りきり、求めていた処理を実現できた時の達成感はひとしおでした。特に大規模開発においては、大量のアクセスやデータ量によって初めて表面化する技術的な難しさもあり、それを肌で感じました。そして長年にわたり培われてきた内製のライブラリやツール群の構造や実装を学べたことは、大規模開発のノウハウを知る上で大変有益で、技術選定や実装の細部が、将来の拡張性や安定性に直結することを深く学びました。さらに方針を変えたのに技術的に問題ないか・運用負荷は大丈夫かどうかを再検討することが何より大切だということも学びました。一度仕様を固めたからといって見直さずに突き進むのではなく、一度立ち止まってこの方針で本当に問題ないか、運用に支障はないかを再考することがとてもとても大事だと思いました。

運用まで考えることの大切さ

運用面では「仕様を作成し、実装して終わり」ではなく、その後の保守性や拡張性を考慮に入れた開発が、いかに大切かを痛感しました。コードの可読性やパフォーマンスはもちろん、「今は問題なくても、将来にわたって大丈夫か」という長期的な視点が求められると感じました。日々変化する要求に対し、どこまでの拡張性や柔軟性を持たせるべきか、そのバランスを見極めるのが難しい点ですが、拡張性を意識しすぎて過度に抽象化すると、かえって開発効率が落ちてしまうこともありますし、逆に密結合な実装では、将来の拡張性を失ってしまいます。このトレードオフを意識しながら、適切なバランスを取ることの難しさを感じました。また実装が完了した後には、必ず仕様書を見直し、実装を通して気づいた曖昧さや矛盾点がないかをチェックする重要性も学びました。

AIの使いこなし方

コードリーディングにおいてもAIをフル活用しました。特に大規模なコードベースから目的のコードを見つけ出し、その振る舞いの解説、さらには新機能追加時のアーキテクチャに沿った設計や配置のアドバイスまで得られたため、非常に助けになりました。また生成されたコードの確認は必要ですが、自然言語で指示するだけでテストケースの生成やmock作成までやってくれて、作業効率化に大きく貢献しました。個人開発でもAIをフル活用していますが、大規模なコードを読む際でも強力な味方となることを実感しました。

コミュニケーションの大切さ・大変さ

多数の人が関わる製品でのコミュニケーションの大切さと大変さを痛感しました。1つの機能を作り上げるだけでも他のサーバーエンジニアさんをはじめクライアントエンジニアさんやプランナーさん、QAさんなどたくさんの方々とコミュニケーションをとりながら仕様を提案して決定して、また仕様が変わるとそれを共有して合意してという風に作り上げていくのは大変でした。ですが、その過程を得ることで機能はブラッシュアップされゲーム的にはより面白く、技術的にはより堅牢に、運営的にはより使いやすいものが出来上がるのだなと感じました。

またインターン生でありながら、様々なミーティングへの参加や、必要な情報へのアクセスが自由にできた点も良かったです。インターン生は権限エラーで必要な情報が全然見れないのはありがちだと思うのですが、今回のインターン中にアクセス制限を受けた事例はほとんどなく、チームの透明性と信頼関係が確立されている証だと感じました。

おわりに

実際の開発現場に飛び込んでみて、普段はできないことをたくさん経験・勉強できた貴重な機会でした。一ヶ月という限られた期間ではあったのですが、楽しく、刺激的で勉強になる日々でした。ありがとうございました!!!