0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【UE】 XREAを用いたサーバーでのスコア集計とランキング機能の実装

0
Last updated at Posted at 2026-03-03

はじめに

UE5.6環境でゲームを作っていたらあまり経験のないバックエンドを体験せざるを得ない状況になったのでつまづいたところや実装の流れなどを共有していきます。

学習途中のため間違っている点や不足している点がありましたら申し訳ありません。

対象

バックエンドの経験が少ない方
UEC++を少しでも読める方(フィーリングでも良い)

やりたいこと

  • ゲームのスコアをサーバーに送信して登録する
  • 登録データの上位100名のスコアや順位と送信元のプレイヤーの順位を取得して表示

本当は取りたかった手段

こちらの方の記事を参考にPlayFabというサービスを用いてデータの登録を行いたかったのですが制作中(2026年1月)のタイミングではPlayFabプラグインがUE5.5までの対応となっており5.6じゃ使えない!!

したがって今回はサーバーをレンタルして自前でDBを構築してといろいろ遠回りすることになりました...(もしこれを見ている人でPlayFabが使えるバージョンや環境であればそちらの方が時間はかからないんじゃないのかなと思います。)

2026年2月現在、PlayFabSDKは5.6に対応してくれました。

全体の流れ

  1. サーバーを借りる
  2. DBの作成
  3. DBに対して操作を行うためのphpを作ってサーバーの中に置く
  4. UE側でHTTP通信を行えるようにする

サーバーを借りる

ユーザーにいつアクセスされてもいいようにサーバーは常時起動されてないといけないですがそれを自前PCで行うのはかなり難しいです。
したがってサーバーをレンタルできるサービスを活用して楽をします。

費用を掛けたくなかったので今回はXREAの無料レンタルサーバーを活用しました。

XREAのサーバーを借りるにはバリュードメインのアカウントを作成後コントロールパネルのサーバーからXREAを開きXREAアカウントを作成することでサーバーを借りられます。

スクリーンショット 2026-01-19 060531.png

DBの作成

サーバーのコントロールパネルからデータベースを選択してデータベースの新規作成を押すとDBの名前とパスワードを決めることができます。

そのまま登録後phpMyAdminのインストールを行います。
これはMySQLが管理するDBの表示や操作をGUIで簡単に行えるツールで、インストールするにはアクション欄の鍵マークをクリックして出てくるインストールページからインストールします。

インストール後phpMyAdminへログインしてテーブルを新規作成します。
スクリーンショット 2026-01-23 213213.png

画像のようにid、ゲームスコア、ユーザー名、更新日時を入れています。(ちゃんとやるなら端末IDとかあったほうがいいかもだけど、今回作ってたゲームは一日限りのイベント用なんでちゃんとやってない)

これでデータを入れる先はできたのであとは入れるための処理を作っていきます!

DBに対して操作を行うためのphpを作ってサーバーの中に置く

操作用phpを作る

UE側から送るのは「ゲームスコア」と「ユーザー名」これを登録するコードを書きます。

put_score.php
<?php
header('Content-Type: application/json; charset=utf-8');

function fail(int $code, string $reason, array $extra = []): void {
  http_response_code($code);
  echo json_encode(['reason'=>$reason] + $extra, JSON_UNESCAPED_UNICODE);
  exit;
}

// NOTE: 認証情報などをヘッダーから取得して通信を確認するべき

// リクエストボディの内容を取得
$json = file_get_contents('php://input');
$data = json_decode($json, true);

$userName = (string)($data['user_name'] ?? '');
$scoreStr = $data['score'] ?? null;

if($userName === '') fail(400, 'missing_userName');
if ($scoreStr === null || !is_numeric($scoreStr)) fail(400, 'invalid_score');

$score = (int)$scoreStr;

// DBの基本情報を設定
// 各自設定したものを入れてください
$dsn = 'mysql:host=localhost;dbname=データーベース名;charset=utf8mb4';
$dbUser = 'データーベースユーザー名';
$dbPass = 'データーベースパス';

// DBにデータを入れていく
try{
    $pdo = new PDO($dsn, $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  ]);

  $sql = "INSERT INTO テーブル名 (score, user_name, update_time)
          VALUES (:score, :user_name, NOW())";
  $stmt = $pdo->prepare($sql);
  $stmt->execute([
    ':score' => $score,
    ':user_name' => $userName,
  ]);

  // 成功
  http_response_code(200);
  echo json_encode(["ok"=>true], JSON_UNESCAPED_UNICODE);
  
}catch (Throwable $e) {
  fail(500, 'sql_error');
}

のようにしてDBに対しスコアと名前を送信していきます。

.php
 $pdo = new PDO($dsn, $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  ]);

とあり、ここでSQLを実行する上での必要なパラメータとオプションを設定しています。
今回はtry-catch文の中で実行するため内部でエラーが起こったらcatchに投げてほしいです。
したがってPDO::ATTR_ERRMODEはPDO::ERRMDE_EXCEPTIONにすることでtry-catch文が正常に機能します。

.php
  $sql = "INSERT INTO テーブル名 (score, user_name, update_time)
          VALUES (:score, :user_name, NOW())";
  $stmt = $pdo->prepare($sql);
  $stmt->execute([
    ':score' => $score,
    ':user_name' => $userName,
  ]);

SQL文を作ってpdoに渡し、文中の変数をアタッチして実行しています。
そのままエラーが起きなければ挿入できたとしてUE側に挿入完了通知を送っています。

操作用phpをサーバーに置く

当然サーバーに作ったphpを置かないとhttp通信で実行できないので置く必要があります。

xreaにはデフォルトでファイルをサーバーに送信できるnet2ftpが設定されているためこちらを用います。
スクリーンショット 2026-02-17 151343.png

サイト設定からnet2ftpファイルマネージャーをクリックするとnet2fptにアクセスできます。
ここからpublic_html以下のファイルの中に操作用phpを置くとhttp通信でアクセスできるようになります。

この記事では書いているphpは全てpublic_html/apiに配置していますが、public_html以下であれば別にそれ以外でもかまいません。(UE側で送信する際のURL設定は変えてね)

UE側でHTTP通信を行えるようにする

前準備

Engineで実装されているHTTPモジュールを読み込むためBuild.csにて

Build.cs
PublicDependencyModuleNames.AddRange("HTTP");

を追加し依存関係に加えます

送信部分

MyHttpLib.h

BPで使えるようにしたいのでブループリント関数ライブラリとして公開していきます。

MyHttpLib.h
#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "HttpModule.h"
#include "MyHttpLib.generated.h"

// 成功
DECLARE_DYNAMIC_DELEGATE_OneParam(FnOnHttpRespose, const FString&, ResponseBody);

// 失敗
DECLARE_DYNAMIC_DELEGATE_OneParam(FOnHttpError, const FString&, Reason);

UCLASS()
class UMyHttpLib : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()

	/* サーバーに対して繋がるか確認します */
	UFUNCTION(BlueprintCallable, Category = "HTTP")
	static void TestServerConnection();

	/* サーバーに対してユーザーデータを送信します */
	UFUNCTION(BlueprintCallable, Category = "HTTP")
	static void PutUserData(FnOnHttpRespose OnResponce, FOnHttpError OnError, const FString& UserName, int Score);

	static const FString SERVER_URL;
	static const float TIME_OUT_SEC;
};

本来の目的としてはPutUserData関数のみで問題ないのですが一旦単純なコードで基本を確かめたいのでTestServerConnection関数があります。

.h
// 成功
DECLARE_DYNAMIC_DELEGATE_OneParam(FnOnHttpRespose, const FString&, ResponseBody);

// 失敗
DECLARE_DYNAMIC_DELEGATE_OneParam(FOnHttpError, const FString&, Reason);

UEC++におけるデリゲートの宣言です。DYNAMICなデリゲートにすることでBP上の関数を呼び出せるようになります。

TestServerConnection関数

MyHttpLib.cpp
void UMyHttpLib::TestServerConnection()
{
	FString apiURL = "connect_test.phpまでのパス (記事通りなら/api/connect_test.php)";
	FString setURL = SERVER_URL+ apiURL;
	
	FHttpModule& httpModule = FHttpModule::Get();
	TSharedRef<IHttpRequest, ESPMode::ThreadSafe> pRequest = httpModule.CreateRequest();
	
	pRequest->SetHeader("Content-Type", "application/json");
	pRequest->SetVerb("GET");
	pRequest->SetURL(setURL);
	pRequest->SetTimeout(TIME_OUT_SEC);

	pRequest->OnProcessRequestComplete().BindLambda([&](
			FHttpRequestPtr pRequest,
			FHttpResponsePtr pResponse,
			bool connectedSuccessfully)
	{
		// そもそも接続できていない
		if (!connectedSuccessfully || !pResponse.IsValid())
		{
			const EHttpFailureReason Reason = pRequest->GetFailureReason();
			UE_LOG(LogTemp, Error, TEXT("Connection failed. Reason=%s"), LexToString(Reason));
			return;
		}
		
		const int32 Code = pResponse->GetResponseCode();
		const FString Body = pResponse->GetContentAsString();
		
		if (EHttpResponseCodes::IsOk(Code))
		{
			UE_LOG(LogTemp, Log, TEXT("HTTP Success. Code=%d Body=%s"), Code, *Body);
		}
		else
		{
		   UE_LOG(LogTemp, Error, TEXT("HTTP Error. Code=%d Body=%s"), Code, *Body);
		}
	});

	pRequest->ProcessRequest();
}

これがHTTP通信の基本の形です。

.cpp
FHttpModule& httpModule = FHttpModule::Get();
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> pRequest = httpModule.CreateRequest();

FHttpModule::Getは単純なシングルトンのゲッター関数でインスタンスを持ってきてるだけです。
取得したインスタンスでHttpリクエストの入れ物を作り、通信に必要な情報を入れていきます。

.cpp
pRequest->SetVerb("GET");
pRequest->SetURL(setURL);
pRequest->SetTimeout(TIME_OUT_SEC);

通信に必要な情報を入れています。データの受け渡しをしないのでVerbはGet
URLとTimeoutはそのままですね

.cpp
pRequest->OnProcessRequestComplete().BindLambda([&](
			FHttpRequestPtr pRequest,
			FHttpResponsePtr pResponse,
			bool connectedSuccessfully)
	{
		// そもそも接続できていない
		if (!connectedSuccessfully || !pResponse.IsValid())
		{
			const EHttpFailureReason Reason = pRequest->GetFailureReason();
			UE_LOG(LogTemp, Error, TEXT("Connection failed. Reason=%s"), LexToString(Reason));
			return;
		}
		
		const int32 Code = pResponse->GetResponseCode();
		const FString Body = pResponse->GetContentAsString();
		
		if (EHttpResponseCodes::IsOk(Code))
		{
			UE_LOG(LogTemp, Log, TEXT("HTTP Success. Code=%d Body=%s"), Code, *Body);
		}
		else
		{
		   UE_LOG(LogTemp, Error, TEXT("HTTP Error. Code=%d Body=%s"), Code, *Body);
		}
	});

OnProcessRequestComplete関数から返されるデリゲートに対しBindLambdaでラムダ式を用いコールバックを登録しています。

返されるデリゲートはFHttpRequestCompleteDelegateでリクエストとレスポンスのポインターと接続の可否が引数として渡されています。

成功している場合と成功していない場合それぞれでUE_LOGでLog表示しているだけですね。

.cpp
pRequest->ProcessRequest();

情報が登録出来たらProcessRequest関数で接続を非同期で開始します。

connection_test.php

テスト送信に対し単純に時間だけ返して接続できてることを示します。

connection_test.php
<?php
header('Content-Type: application/json; charset=utf-8');

http_response_code(200);

echo json_encode([
    'timestamp' => date('c')
], JSON_UNESCAPED_UNICODE);

UEで接続できているかがわかりやすくなったはずです。

PutUserData関数

.cpp
void UMyHttpLib::PutUserData(FnOnHttpRespose OnResponce, FOnHttpError OnError, const FString& UserName, int Score)
{
	FString apiURL = "put_score.phpまでのパス";
	FString setURL = SERVER_URL + apiURL;
	
	FHttpModule& httpModule = FHttpModule::Get();
	TSharedRef<IHttpRequest, ESPMode::ThreadSafe> pRequest = httpModule.CreateRequest();
	
	pRequest->SetHeader("Content-Type", "application/json");
	pRequest->SetVerb("POST");
	pRequest->SetURL(setURL);
	pRequest->SetTimeout(TIME_OUT_SEC);

	// phpに送るデータを設定する
	FString RequestContent =  FString::Printf(TEXT("{\"user_name\":\"%s\",\"score\":%d}"), *UserName,Score);
	pRequest->SetContentAsString(RequestContent);

	
	pRequest->OnProcessRequestComplete().BindLambda([OnResponce, OnError](
			FHttpRequestPtr pRequest,
			FHttpResponsePtr pResponse,
			bool connectedSuccessfully)
	{
		// そもそも接続できていない
		if (!connectedSuccessfully || !pResponse.IsValid())
		{
			const EHttpFailureReason Reason = pRequest->GetFailureReason();
			OnError.ExecuteIfBound(FString::Printf(TEXT("Connection failed. Reason=%s"), LexToString(Reason)));
			return;
		}
		
		const int32 Code = pResponse->GetResponseCode();
		const FString Body = pResponse->GetContentAsString();
		
		if (EHttpResponseCodes::IsOk(Code))
		{
			OnResponce.ExecuteIfBound(Body);
		}
		else
		{
			const EHttpFailureReason Reason = pRequest->GetFailureReason();
			OnError.ExecuteIfBound(FString::Printf(TEXT("Connection failed. Reason=%s"), *Body));
		}
	});

	pRequest->ProcessRequest();
}

基本は接続テストと同じですがいくつかデータを送るための違いがあります。

.cpp
pRequest->SetVerb("POST");

VerbがPostに設定されています。情報の取得ではなく設定なのでPOSTです。
別にphp側でメソッド分岐してないからあってもなくても変わらないけど

.cpp
// phpに送るデータを設定する
	FString RequestContent =  FString::Printf(TEXT("{\"user_name\":\"%s\",\"score\":%d}"), *UserName,Score);
	pRequest->SetContentAsString(RequestContent);

コメント通りです。Json記法に合わせてデータを作成し
SetContentAsString関数でリクエストに設定しています。

ここまで作れば今回はテストなんで適当にLevelBP上で...

スクリーンショット 2026-02-26 180448.png

ってやると

スクリーンショット 2026-02-26 180537.png

okらしいですのでDB見に行ってみると

スクリーンショット 2026-02-26 180649.png

入ってる!(idが5なのは裏でテストしてたため)
ユーザーがどんどん遊ぶと...

スクリーンショット 2026-02-26 180901.png
こんな感じでそれっぽくなりますね

ランキングを取得する

ランキング取得php

データの送信はできたのであとは受け取りです。

受け取る用のphpでDBに格納されているユーザーデータをスコア順に並び替えて指定した範囲の順位のユーザーデータを返す形にしていきます。

get_ranking.php
<?php
header('Content-Type: application/json; charset=utf-8');

function fail(int $code, string $reason, array $extra = []): void {
  http_response_code($code);
  echo json_encode(['reason'=>$reason] + $extra, JSON_UNESCAPED_UNICODE);
  exit;
}

// リクエストボディの内容を取得
$json = file_get_contents('php://input');
$data = json_decode($json, true);

$getMaxRankStr = $data['max_rank'] ?? null; 
$getMinRankStr = $data['min_rank'] ?? null; 

if ($getMaxRankStr === null || !is_numeric($getMaxRankStr)) fail(400, 'invalid_maxrank');
if ($getMinRankStr === null || !is_numeric($getMinRankStr)) fail(400, 'invalid_minrank');

$getMaxRank = (int)$getMaxRankStr;
$getMinRank = (int)$getMinRankStr;

if ($getMinRank < 1 || $getMaxRank < $getMinRank) fail(400, 'invalid_rank_range');

// DBの基本情報を設定
$dsn = 'mysql:host=localhost;dbname=baconslice_qiita;charset=utf8mb4';
$dbUser = 'baconslice_qiita';
$dbPass = 'qiitatest';

// DBからデータを抽出
try {
  $pdo = new PDO($dsn, $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  ]);

  // ランキング取得(同点は更新日が早い方が上)
  $sql = "
    SELECT rank, user_name, score
    FROM (
      SELECT
        ROW_NUMBER() OVER (ORDER BY score DESC, update_time ASC) AS rank,
        user_name,
        score
      FROM qiites
    ) t
    WHERE t.rank BETWEEN :minRank AND :maxRank
    ORDER BY t.rank ASC
  ";

  $stmt = $pdo->prepare($sql);
  $stmt->execute([
    ':minRank' => $getMinRank,
    ':maxRank' => $getMaxRank,
  ]);

  $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

  echo json_encode([
    'min_rank' => $getMinRank,
    'max_rank' => $getMaxRank,
    'users' => $rows,
  ], JSON_UNESCAPED_UNICODE);

} catch (Throwable $e) {
  fail(500, 'sql_error');
}

ランキング取得UE側

次にUE側のコードを書いていきます。
ランキングデータを構造体で保持したいので

.h
/*ランキングデータ*/
USTRUCT(BlueprintType)
struct FRankingUserData
{
	GENERATED_BODY()
	
	UPROPERTY(BlueprintReadOnly)
	FString UserName;
	UPROPERTY(BlueprintReadOnly)
	int Score;
	UPROPERTY(BlueprintReadOnly)
	int Rank;
};

として構造体を宣言します。
新しいファイルに分けて書くのが一番良いと思いますがテストなので私はMyHttpLib.hに付け加えています。

構造体を宣言したら次はランキングデータを受け取った後それをコールバックに返したいため新しくデリゲートを宣言します。これはMyHttpLib.hの上に付け加えてください。

MyHttpLib.h
DECLARE_DYNAMIC_DELEGATE_OneParam(FnOnHttpGetRankingBoard, const TArray<FRankingUserData>&, RankingBoard);

これを用いた受け取り用関数の宣言もクラス内に追加し、実装も書いていきます。

MyHttpLib.h
/* サーバーに対して指定した範囲の順位のランキングボードを要求します */
UFUNCTION(Blueprintcallable, Category = "HTTP")
static void GetRankingBoard(FnOnHttpGetRankingBoard OnResponce, FOnHttpError OnError, int MaxRank, int MinRank);
.cpp
void UMyHttpLib::GetRankingBoard(FnOnHttpGetRankingBoard OnResponce, FOnHttpError OnError, int MaxRank, int MinRank)
{
	FString apiURL = "get_ranking.phpまでのパス";
	FString setURL = SERVER_URL + apiURL;
	
	FHttpModule& httpModule = FHttpModule::Get();
	TSharedRef<IHttpRequest, ESPMode::ThreadSafe> pRequest = httpModule.CreateRequest();
	
	pRequest->SetHeader("Content-Type", "application/json");
	pRequest->SetVerb("POST");
	pRequest->SetURL(setURL);
	pRequest->SetTimeout(TIME_OUT_SEC);

	// phpに送るデータを設定する
	FString RequestContent =  FString::Printf(TEXT("{\"max_rank\":\"%d\",\"min_rank\":%d}"), MaxRank,MinRank);
	pRequest->SetContentAsString(RequestContent);

	pRequest->OnProcessRequestComplete().BindLambda([OnResponce, OnError](
			FHttpRequestPtr pRequest,
			FHttpResponsePtr pResponse,
			bool connectedSuccessfully)
	{
		// そもそも接続できていない
		if (!connectedSuccessfully || !pResponse.IsValid())
		{
			const EHttpFailureReason Reason = pRequest->GetFailureReason();
			OnError.ExecuteIfBound(FString::Printf(TEXT("Connection failed. Reason=%s"), LexToString(Reason)));
			return;
		}
		
		const int32 Code = pResponse->GetResponseCode();
		const FString Body = pResponse->GetContentAsString();
		
		if (EHttpResponseCodes::IsOk(Code))
		{
			// BodyをRankingUserDataのArrayに分解する
			TArray<FRankingUserData> OutUsers;
			
			TSharedPtr<FJsonObject> RootObj;
			const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Body);

			if (!FJsonSerializer::Deserialize(Reader, RootObj) || !RootObj.IsValid())
			{
				OnError.ExecuteIfBound(TEXT("Invalid JSON response"));
				return;
			}

			// users 配列を取り出す
			const TArray<TSharedPtr<FJsonValue>>* UsersArray = nullptr;
			if (!RootObj->TryGetArrayField(TEXT("users"), UsersArray) || UsersArray == nullptr)
			{
				OnError.ExecuteIfBound(TEXT("Missing 'users' field"));
				return;
			}

			OutUsers.Reserve(UsersArray->Num());

			for (const TSharedPtr<FJsonValue>& V : *UsersArray)
			{
				const TSharedPtr<FJsonObject> UserObj = V->AsObject();
				if (!UserObj.IsValid()) continue;

				FRankingUserData Item;
				Item.Rank = UserObj->GetIntegerField(TEXT("rank"));
				Item.Score = UserObj->GetIntegerField(TEXT("score"));
				Item.UserName = UserObj->GetStringField(TEXT("user_name"));

				OutUsers.Add(Item);
			}
			
			
			OnResponce.ExecuteIfBound(OutUsers);
		}
		else
		{
			const EHttpFailureReason Reason = pRequest->GetFailureReason();
			OnError.ExecuteIfBound(FString::Printf(TEXT("Connection failed. Reason=%s"), *Body));
		}
	});

	pRequest->ProcessRequest();
}

送信完了後のコールバック中でphpから送られるBodyをJsonとして認識し、それを要素ごとに分解している処理が増えたのが大きな違いですね。

そしてこれをこのようにしてつないで...
スクリーンショット 2026-02-27 042853.png

ForEach内がごちゃついてますがつまるところ「{順位}{名前}{スコア}」とPrintStringで表示したいということです。

実行してみると
スクリーンショット 2026-02-27 042957.png

おお!私最下位じゃん!もうやらないこのゲーム!!
という風にランキングが確認できました。

問題点

この記事ではXREAの使用、UEのHttpモジュールを使った基本的な通信に焦点を当てて実装してきました。

ただちゃんとやるなら問題点は結構あります。
セキュリティ面、ユーザー名の被り、非同期処理などなど...
セキュリティは今回は省いた認証処理、ユーザー名に関しては被りありにするならそれ以外でプレイヤーを区別できるようUserIdを用意するなど、非同期処理は...ゲームによってまちまちなので各自頑張ってください。 セキュリティ全然わからないので私はそこを頑張る

おわり

サーバー構築からapi実装とUE側と一連をつなげて解説してみました。
実装理解やどう手を付ければいいかという指標の一つとして参考になれば幸いです。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?