Solo game jam project, made during my 1st year at BUas
last updated 01.12.22

POLYSPACE is a game I made in October 2020 as a weekend game jam organized by our school. It ended up winning the #1 spot :D
I wanted to make a cool and slightly hard speedrunning game with lots of visual juice because it seemed fun, so I went ahead and did it.

The game features a few interesting mechanics and many VFX to spice it up. The use of the double-jump dictates how fast you're going to be.

I thought it would be a cool idea to implement a global leaderboard, to keep players coming back to the game and create a sense of competition between players. This was a very successful idea and it really created competition between the top 10 players.

🏅 Global Leaderboard

🔢 Live scores

Player NameTime
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...
Loading...Loading...

💻 Making the leaderboard

I really like escaping from the engine and enabling the game to communicate with external resources or do remote things, so for POLYSPACE I implemented a global leaderboard system.

At large, it communicates through HTTP requests to my server, which stores the scores, sorts them by lowest time, and returns them to a static leaderboard page, which I then pull back & display in the game.

The in-game part in made in C++ which exposes some functions to Blueprints to tie the system together, whereas the server part is made in PHP. The data is sent in JSON format to make it easy for both ends.

💫 The catch of POLYSPACE

It's all in the double jump

Because it's such a small game with few mechanics, I thought I'd put a spin on those mechanics. The double jump is a vital part of the game.

In short, the double jump pushes you higher, the sooner you follow up the first jump with the second one. This is the other way around than what you would usually see with other double jump implementations.

When playtesting, this was often confusing and players wouldn't even manage to finish the first level, but an interesting pattern emerged...

While some players couldn't even pass a level, those who could ended up playing for upwards of 1-2 hours, to improve their performance and score!

⚙️ Global Leaderboard - System Breakdown

Client-side - The C++ functions

Client-side, the entire system is handled through 3 C++ functions: FetchScores()PushScores() and OnResponseReceived().
When the leaderboard is accessed, FetchScores() is called and the data gets fetched, sorted and then fed into the leaderboard table.

FetchScores()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void ALeaderboardManager::FetchScores(FString ActualPlayerName)
{
	TSharedRef<IHttpRequest> Request = Http->CreateRequest();
	Request->OnProcessRequestComplete().BindUObject(this, &ALeaderboardManager::OnResponseReceived);

    PlayerName = ActualPlayerName;

	Request->SetURL("https://stats.kronorite.com/polyspace/leaderboard.php");
	Request->SetVerb("GET");
	Request->SetHeader(TEXT("User-Agent"), "X-UnrealEngine-Agent");
	Request->SetHeader("Content-Type", TEXT("application/json"));
	Request->ProcessRequest();
}

FetchScores() does a normal HTTP request to the API link, which returns a JSON string with all names and their scores. On lines 8 to 11 I am defining request headers, which are needed to identify who sent the request. They can be used for things such as server-side authentication.

PushScores()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void ALeaderboardManager::PushScores(FString ActualPlayerName, float FinishTime)
{
	TSharedRef<IHttpRequest> Request = Http->CreateRequest();

	FetchScores(ActualPlayerName);

	int seconds = FinishTime*100;

	FString FFinish = FString::Printf(TEXT("%d"), seconds);

	Request->SetURL("https://stats.kronorite.com/polyspace/retrieve.php?playername=" + ActualPlayerName + "&score=" + FFinish);
	Request->SetVerb("GET");
	Request->SetHeader(TEXT("User-Agent"), "X-UnrealEngine-Agent");
	Request->SetHeader("Content-Type", TEXT("application/json"));
	Request->ProcessRequest();
}

PushScores() does an HTTP request to the API and supplies the player name and the score through URL parameters, which I am retrieving server-side.

OnResponseReceived()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void ALeaderboardManager::OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful)
{
    ScoreTableString.Empty();
    ScoreTableInt.Empty();

    TPair<int, FString> Entry;

    TArray<TPair<int, FString>> ScoreTable;

    TSharedPtr<FJsonObject> JsonObject;

    TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString());

    if (FJsonSerializer::Deserialize(Reader, JsonObject))
    {
        // fetch data

        for (auto it : JsonObject->Values) {
            Entry.Key = it.Value->AsNumber();
            Entry.Value = it.Key;
            ScoreTable.Add(Entry);
            if (it.Key == PlayerName)
            {
                int miliseconds = Entry.Key;
                FString minutes = FString::Printf(TEXT("%d"), (miliseconds / 6000));
                FString seconds = FString::Printf(TEXT("%d"), ((miliseconds / 100) % 60));
                FString FFinish = FString::Printf(TEXT("%02d:%02d:%02d"), (miliseconds / 6000), ((miliseconds / 100) % 60), (miliseconds % 100));
                BestScore = FFinish;
            }
        }

        ScoreTable.Sort();

        for (int i = 0; i < 10 && i < ScoreTable.Num(); i++) {
            int miliseconds = ScoreTable[i].Key;
            FString minutes = FString::Printf(TEXT("%d"), (miliseconds / 6000));
            FString seconds = FString::Printf(TEXT("%d"), ((miliseconds / 100) % 60));
            FString FFinish = FString::Printf(TEXT("%02d:%02d:%02d"), (miliseconds / 6000), ((miliseconds / 100) % 60), (miliseconds % 100));
            ScoreTableString.Add(ScoreTable[i].Value);
            ScoreTableInt.Add(FFinish);
        }
    }
}

OnResponseReceived() is an asynchronous function which gets fired when the request has finished, and retrieves the JSON response. I start by emptying the name and score arrays. I then define some variables, retrieve the JSON data from the server, deserialize it and do the necessary formatting and calculations to turn it into a MM:SS:MLS format and pair the usernames with their scores. Finally, I populate the score table.

Client-side - The interface

The first thing you see when you boot up the game is a prompt asking for your name. Upon entering your name, the game immediately starts. I took this decision to make the game more immersive during your first playthrough.

If you fail and you don't manage to get a score, the main menu will just display "NO ATTEMPT" as your current best score. The right side will also display your name which it pulls from a local save file.

The scores are pulled every time you check the leaderboard, to make sure they're up to date. The best time is pulled from the server using the same function for pushing scores.

Server-side - The retriever & leaderboard scripts

The server-side API is composed out of 2 scripts: retrieve.php and leaderboard.php.

retrieve.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

session_start();

$score = $_GET['score'];
$playername = $_GET['playername'];

// debug
echo $playername . ": " . $score;

$content = $score;

if (!file_exists($playername . ".dat") || $score < file_get_contents($playername . ".dat"))
{
	$fp = fopen($playername . ".dat", "w");
        fwrite($fp,$content);
        fclose($fp);
}
else {echo '';}

?>

leaderboard.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php

$fileList = glob("*.dat");

$filecount = count($fileList);

foreach ($fileList as $fl) {
    $contents = file_get_contents($fl);
    $filename = substr($fl, 0, strrpos($fl, '.'));
    $output[$filename] = $contents;
}
echo json_encode($output);

?>

At the time of implementation, I hadn't worked with storing such data in a database, so I just used basic files for storing scores. I just write them, read their values and display it on the leaderboard.

Later on, when working on Vana, I made a feedback/bug reporting plugin for Unreal Engine, and for that one I delved into storing data into a MySQL database instead of plain files. For that project, I created an entire webpanel with customizable options for filtering through bug reports.

The End!

Go check out one of my other projects!