diff --git a/Game/Assets/Scripts.meta b/Game/Assets/Scripts.meta
new file mode 100644
index 0000000..04830a9
--- /dev/null
+++ b/Game/Assets/Scripts.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b2fa2ae79460cd147a2f52ae9879eb9f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Game/Assets/Scripts/Backend.cs b/Game/Assets/Scripts/Backend.cs
new file mode 100644
index 0000000..c728297
--- /dev/null
+++ b/Game/Assets/Scripts/Backend.cs
@@ -0,0 +1,690 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Firebase;
+using Firebase.Auth;
+using Firebase.Database;
+using Firebase.Extensions;
+using UnityEngine;
+
+///
+/// the general managing class for handling communication with the firebase backend
+/// (to be initialised by GameManager)
+///
+public class Backend
+{
+ ///
+ /// enum for the result of the authentication process
+ ///
+ public enum AuthenticationResult
+ {
+ Ok,
+ AlreadyAuthenticated,
+ NonExistentUser,
+ AlreadyExistingUser,
+ UsernameAlreadyTaken,
+ InvalidEmail,
+ InvalidCredentials,
+ GenericError
+ }
+
+ ///
+ /// enum for the connection status of the firebase backend
+ ///
+ public enum FirebaseConnectionStatus
+ {
+ NotConnected,
+ Connected,
+ UpdateRequired, // "a required system component is out of date"
+ Updating, // "a required system component is updating, retrying in a bit..."
+ ExternalError, // "a system component is disabled, invalid, missing, or permissions are insufficient"
+ InternalError // "an unknown error occurred"
+ }
+
+ ///
+ /// generic enum for the result of a database transaction
+ ///
+ public enum TransactionResult
+ {
+ Ok,
+ Unauthenticated,
+ Error
+ }
+
+ public enum UserAccountDetailTargetEnum
+ {
+ Username,
+ Email,
+ Password
+ }
+
+ ///
+ /// callback functions to be invoked when the connection status changes
+ ///
+ ///
+ private readonly List> _onConnectionStatusChangedCallbacks = new();
+
+ ///
+ /// callback functions to be invoked when the user signs in
+ ///
+ private readonly List> _onSignInCallbacks = new();
+
+ ///
+ /// callback functions to be invoked when the user signs out
+ ///
+ private readonly List _onSignOutCallbacks = new();
+
+ ///
+ /// the firebase authentication object
+ ///
+ private FirebaseAuth _auth;
+
+ ///
+ /// the firebase database reference
+ ///
+ private DatabaseReference _db;
+
+ ///
+ /// the current user object, if authenticated
+ ///
+ private FirebaseUser _user;
+
+ ///
+ /// the current user's username, if authenticated
+ ///
+ private string _username;
+
+ ///
+ /// whether the user is signed in
+ ///
+ public bool IsSignedIn;
+
+ ///
+ /// whether the backend is connected to the firebase backend
+ ///
+ public FirebaseConnectionStatus Status = FirebaseConnectionStatus.NotConnected;
+
+ ///
+ /// variable initialisation function
+ ///
+ public void Initialise(Action callback)
+ {
+ FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task =>
+ {
+ // cher is this robust enough
+ switch (task.Result)
+ {
+ case DependencyStatus.Available:
+ _auth = FirebaseAuth.GetAuth(FirebaseApp.DefaultInstance);
+ _auth.StateChanged += AuthStateChanged;
+ _db = FirebaseDatabase.DefaultInstance.RootReference;
+ Status = FirebaseConnectionStatus.Connected;
+ callback(Status);
+ FireOnConnectionStatusChangedCallbacks();
+ break;
+
+ case DependencyStatus.UnavailableDisabled:
+ case DependencyStatus.UnavailableInvalid:
+ case DependencyStatus.UnavilableMissing:
+ case DependencyStatus.UnavailablePermission:
+ Status = FirebaseConnectionStatus.ExternalError;
+ callback(Status);
+ FireOnConnectionStatusChangedCallbacks();
+ break;
+
+ case DependencyStatus.UnavailableUpdating:
+ Status = FirebaseConnectionStatus.Updating;
+ callback(Status);
+ FireOnConnectionStatusChangedCallbacks();
+ RetryInitialiseAfterDelay(callback);
+ break;
+
+ case DependencyStatus.UnavailableUpdaterequired:
+ Status = FirebaseConnectionStatus.UpdateRequired;
+ FireOnConnectionStatusChangedCallbacks();
+ callback(Status);
+ break;
+
+ case DependencyStatus.UnavailableOther:
+ default:
+ Status = FirebaseConnectionStatus.InternalError;
+ Debug.LogError("firebase ??? blew up or something," + task.Result);
+ FireOnConnectionStatusChangedCallbacks();
+ callback(Status);
+ break;
+ }
+
+ Debug.Log("firebase status is" + Status);
+ });
+ }
+
+ ///
+ /// async function to retry initialisation after a delay
+ ///
+ private async void RetryInitialiseAfterDelay(Action callback)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(10));
+ Initialise(callback);
+ }
+
+ ///
+ /// cleanup function
+ ///
+ public void Cleanup()
+ {
+ SignOutUser();
+ _auth.StateChanged -= AuthStateChanged;
+ _auth = null;
+ }
+
+ ///
+ /// function to register a callback for when the user signs in
+ ///
+ /// callback function that takes in a FirebaseUser object
+ public void RegisterOnSignInCallback(Action callback)
+ {
+ _onSignInCallbacks.Add(callback);
+ Debug.Log($"registering OnSignInCallback ({_onSignInCallbacks.Count})");
+ }
+
+ ///
+ /// function to register a callback for when the user signs out
+ ///
+ /// callback function
+ public void RegisterOnSignOutCallback(Action callback)
+ {
+ _onSignOutCallbacks.Add(callback);
+ Debug.Log($"registering OnSignOutCallback ({_onSignOutCallbacks.Count})");
+ }
+
+ ///
+ /// function to register a callback for when the connection status changes
+ ///
+ /// callback function that takes in a FirebaseConnectionStatus enum
+ public void RegisterOnConnectionStatusChangedCallback(Action callback)
+ {
+ _onConnectionStatusChangedCallbacks.Add(callback);
+ Debug.Log($"registering ConnectionStatusChangedCallback ({_onConnectionStatusChangedCallbacks.Count})");
+ }
+
+ ///
+ /// function to fire all on sign in callbacks
+ ///
+ private void FireOnSignInCallbacks()
+ {
+ Debug.Log($"firing OnSignInCallbacks ({_onSignInCallbacks.Count})");
+ foreach (var callback in _onSignInCallbacks)
+ try
+ {
+ callback.Invoke(_user);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"error invoking OnSignInCallback: {e.Message}");
+ }
+ }
+
+ ///
+ /// function to fire all on sign-out callbacks
+ ///
+ private void FireOnSignOutCallbacks()
+ {
+ Debug.Log($"firing OnSignOutCallbacks ({_onSignOutCallbacks.Count})");
+ foreach (var callback in _onSignOutCallbacks)
+ try
+ {
+ callback.Invoke();
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"error invoking OnSignOutCallback: {e.Message}");
+ }
+ }
+
+ ///
+ /// function to fire all on connection status changed callbacks
+ ///
+ private void FireOnConnectionStatusChangedCallbacks()
+ {
+ Debug.Log($"firing OnConnectionStatusChangedCallbacks ({_onConnectionStatusChangedCallbacks.Count})");
+ foreach (var callback in _onConnectionStatusChangedCallbacks)
+ try
+ {
+ callback.Invoke(Status);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"error invoking OnConnectionStatusChangedCallback: {e.Message}");
+ }
+ }
+
+ ///
+ /// function to handle the authentication state change event
+ ///
+ /// the object that triggered the event
+ /// the event arguments
+ private void AuthStateChanged(object sender, EventArgs eventArgs)
+ {
+ // if the user hasn't changed, do nothing
+ if (_auth.CurrentUser == _user) return;
+
+ // if the user has changed, check if they've signed in or out
+ IsSignedIn = _user != _auth.CurrentUser && _auth.CurrentUser != null;
+
+ // if we're not signed in, but we still hold _user locally, we've signed out
+ if (!IsSignedIn && _user != null)
+ {
+ Debug.Log("moi-moi");
+ FireOnSignOutCallbacks();
+ }
+
+ // they have signed in, update _user
+ _user = _auth.CurrentUser;
+ if (!IsSignedIn) return;
+
+ Debug.Log($"signed in successfully as {_user?.UserId}");
+ RetrieveUsernameWithCallback((_, _) => { FireOnSignInCallbacks(); });
+ }
+
+ ///
+ /// abstraction function to authenticate the user
+ ///
+ /// email string
+ /// user raw password string
+ /// callback function that takes in an AuthenticationResult enum
+ /// whether to treat authentication as registration
+ /// username string if registering
+ public void AuthenticateUser(
+ string email,
+ string password,
+ Action callback,
+ bool registerUser = false,
+ string registeringUsername = "")
+ {
+ if (GameManager.Instance.Backend.GetUser() != null)
+ {
+ callback(AuthenticationResult.AlreadyAuthenticated);
+ return;
+ }
+
+ if (registerUser)
+ {
+ // check if the username is already taken
+ _db.Child("users")
+ .OrderByChild("username")
+ .EqualTo(registeringUsername)
+ .GetValueAsync()
+ .ContinueWithOnMainThread(task =>
+ {
+ if (task.Exception != null)
+ {
+ Debug.LogError(task.Exception);
+ callback(AuthenticationResult.GenericError);
+ return;
+ }
+
+ if (!task.IsCompletedSuccessfully || task.Result.ChildrenCount > 0)
+ {
+ callback(AuthenticationResult.UsernameAlreadyTaken);
+ return;
+ }
+
+ // register user
+ _auth.CreateUserWithEmailAndPasswordAsync(email, password)
+ .ContinueWithOnMainThread(createTask =>
+ {
+ if (createTask.IsCompletedSuccessfully)
+ {
+ // store username
+ _db.Child("users")
+ .Child(_user.UserId)
+ .Child("username")
+ .SetValueAsync(registeringUsername)
+ .ContinueWithOnMainThread(setUsernameTask =>
+ {
+ if (setUsernameTask.IsCompletedSuccessfully)
+ {
+ _username = registeringUsername;
+ callback(AuthenticationResult.Ok);
+ }
+ else
+ {
+ Debug.LogError(setUsernameTask.Exception);
+ callback(AuthenticationResult.GenericError);
+ }
+ });
+ return;
+ }
+
+ if (createTask.Exception?.InnerException == null)
+ {
+ callback(AuthenticationResult.GenericError);
+ return;
+ }
+
+ var error = (AuthError)((FirebaseException)createTask.Exception.InnerException).ErrorCode;
+
+ // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
+ switch (error)
+ {
+ case AuthError.UserNotFound:
+ callback(AuthenticationResult.NonExistentUser);
+ return;
+
+ case AuthError.InvalidEmail:
+ callback(AuthenticationResult.InvalidEmail);
+ return;
+
+ case AuthError.WeakPassword:
+ case AuthError.InvalidCredential:
+ callback(AuthenticationResult.InvalidCredentials);
+ return;
+
+ case AuthError.AccountExistsWithDifferentCredentials:
+ case AuthError.EmailAlreadyInUse:
+ callback(AuthenticationResult.AlreadyExistingUser);
+ return;
+
+ case AuthError.Failure:
+ default:
+ Debug.LogError(error);
+ Debug.LogError(createTask.Exception);
+ callback(AuthenticationResult.GenericError);
+ break;
+ }
+ });
+ });
+ return;
+ }
+
+ // logging in
+ _auth.SignInWithEmailAndPasswordAsync(email, password)
+ .ContinueWithOnMainThread(signInTask =>
+ {
+ if (signInTask.IsCompletedSuccessfully)
+ {
+ RetrieveUsername();
+ callback(AuthenticationResult.Ok);
+ return;
+ }
+
+ if (signInTask.Exception?.InnerException == null)
+ {
+ callback(AuthenticationResult.GenericError);
+ return;
+ }
+
+ var error = (AuthError)((FirebaseException)signInTask.Exception.InnerException).ErrorCode;
+
+ // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
+ switch (error)
+ {
+ case AuthError.UserNotFound:
+ callback(AuthenticationResult.NonExistentUser);
+ return;
+ case AuthError.InvalidEmail:
+ callback(AuthenticationResult.InvalidEmail);
+ return;
+ case AuthError.WeakPassword:
+ case AuthError.InvalidCredential:
+ callback(AuthenticationResult.InvalidCredentials);
+ return;
+ case AuthError.AccountExistsWithDifferentCredentials:
+ case AuthError.EmailAlreadyInUse:
+ callback(AuthenticationResult.AlreadyExistingUser);
+ return;
+ case AuthError.Failure:
+ default:
+ Debug.LogError(error);
+ Debug.LogError(signInTask.Exception);
+ callback(AuthenticationResult.GenericError);
+ break;
+ }
+ });
+ }
+
+ ///
+ /// helper function to run RetrieveUsername with no callback
+ ///
+ private void RetrieveUsername()
+ {
+ RetrieveUsernameWithCallback((_, _) => { });
+ }
+
+ ///
+ /// function to retrieve the user's username from the database
+ ///
+ private void RetrieveUsernameWithCallback(Action callback)
+ {
+ if (!Status.Equals(FirebaseConnectionStatus.Connected)) return;
+
+ if (_user == null)
+ {
+ Debug.LogError("receiving username post-authentication but user is null (should be unreachable)");
+ callback(TransactionResult.Unauthenticated, "Unknown");
+ return;
+ }
+
+ _db.Child("users")
+ .Child(_user.UserId)
+ .Child("username")
+ .GetValueAsync()
+ .ContinueWithOnMainThread(task =>
+ {
+ TransactionResult result;
+ if (task.IsCompletedSuccessfully)
+ {
+ result = TransactionResult.Ok;
+ _username = task.Result.Value.ToString();
+ Debug.Log($"our username is {_username}");
+ }
+ else
+ {
+ result = TransactionResult.Error;
+ _username = "Unknown";
+ Debug.LogError("failed to get username");
+ }
+
+ callback(result, _username);
+ });
+ }
+
+ ///
+ /// abstraction function to retrieve the user
+ ///
+ /// the firebase user object
+ public FirebaseUser GetUser()
+ {
+ return _user;
+ }
+
+ public string GetUsername()
+ {
+ return _username;
+ }
+
+ ///
+ /// abstraction function to sign out the user
+ ///
+ public void SignOutUser()
+ {
+ _auth.SignOut();
+ }
+
+ ///
+ /// abstraction function to delete the user
+ ///
+ public void DeleteUser(Action callback)
+ {
+ if (!Status.Equals(FirebaseConnectionStatus.Connected))
+ {
+ callback(TransactionResult.Error);
+ return;
+ }
+
+ if (_user == null)
+ {
+ callback(TransactionResult.Unauthenticated);
+ return;
+ }
+
+ _user.DeleteAsync().ContinueWithOnMainThread(task =>
+ {
+ if (task.IsCompletedSuccessfully)
+ {
+ Debug.Log("user deleted");
+ _user = null;
+ FireOnSignOutCallbacks();
+ callback(TransactionResult.Ok);
+ }
+ else
+ {
+ Debug.LogError($"error deleting user: {task.Exception}");
+ callback(TransactionResult.Error);
+ }
+ });
+ }
+
+ ///
+ /// abstraction function for the user to reset their password
+ ///
+ /// the forgetful user's email lol
+ /// callback function to be invoked after the password reset email is sent
+ public void ResetUserPassword(string email, Action callback)
+ {
+ _auth.SendPasswordResetEmailAsync(email)
+ .ContinueWithOnMainThread(resetTask =>
+ {
+ if (resetTask.IsCompletedSuccessfully)
+ {
+ callback(true);
+ }
+ else
+ {
+ Debug.LogError(resetTask.Exception);
+ callback(false);
+ }
+ });
+ }
+
+ ///
+ /// abstraction function to get the leaderboard from the database
+ ///
+ ///
+ /// callback function that takes in a TransactionResult enum and a List<LeaderboardEntry>
+ ///
+ public void GetLeaderboard(
+ Action> callback)
+ {
+ Debug.Log("getting leaderboard");
+
+ _db.Child("users")
+ .OrderByChild("rating")
+ .LimitToLast(Leaderboard.MaxEntries)
+ .GetValueAsync()
+ .ContinueWithOnMainThread(task =>
+ {
+ if (!task.IsCompletedSuccessfully)
+ {
+ Debug.LogError(task.Exception);
+ callback(TransactionResult.Error, new List(0));
+ return;
+ }
+
+ var entries = new List();
+ foreach (var child in task.Result.Children)
+ try
+ {
+ var entry = new LeaderboardEntry(child.Value as Dictionary);
+ entries.Add(entry);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError(e);
+ }
+
+ callback(TransactionResult.Ok, entries);
+ });
+ }
+
+ ///
+ /// abstraction function to update the user's account details in the database
+ ///
+ /// the target account detail to update
+ /// the new value for the target account detail
+ /// callback function that takes in a TransactionResult enum
+ /// thrown when the target is not a valid UserAccountDetailTargetEnum
+ public void UpdateUserAccountDetail(
+ UserAccountDetailTargetEnum target,
+ string newValue,
+ Action callback)
+ {
+ if (!Status.Equals(FirebaseConnectionStatus.Connected)) callback(TransactionResult.Unauthenticated);
+
+ if (_user == null)
+ {
+ callback(TransactionResult.Unauthenticated);
+ return;
+ }
+
+ switch (target)
+ {
+ case UserAccountDetailTargetEnum.Email:
+ _user.SendEmailVerificationBeforeUpdatingEmailAsync(newValue).ContinueWithOnMainThread(task =>
+ {
+ if (task.IsCompletedSuccessfully)
+ {
+ GameManager.Instance.Data.LastKnownEmail = newValue;
+ GameManager.Instance.FireLocalPlayerDataChangeCallbacks(GameManager.Instance.Data);
+ callback(TransactionResult.Ok);
+ }
+ else
+ {
+ Debug.LogError(task.Exception);
+ callback(TransactionResult.Error);
+ }
+ });
+ break;
+
+ case UserAccountDetailTargetEnum.Username:
+ _db.Child("users")
+ .Child(_user.UserId)
+ .Child("username")
+ .SetValueAsync(newValue)
+ .ContinueWithOnMainThread(task =>
+ {
+ if (task.IsCompletedSuccessfully)
+ {
+ _username = newValue;
+ GameManager.Instance.Data.LastKnownUsername = newValue;
+ GameManager.Instance.FireLocalPlayerDataChangeCallbacks(GameManager.Instance.Data);
+ callback(TransactionResult.Ok);
+ }
+ else
+ {
+ Debug.LogError(task.Exception);
+ callback(TransactionResult.Error);
+ }
+ });
+ break;
+
+ case UserAccountDetailTargetEnum.Password:
+ _user.UpdatePasswordAsync(newValue).ContinueWithOnMainThread(task =>
+ {
+ if (task.IsCompletedSuccessfully)
+ {
+ callback(TransactionResult.Ok);
+ }
+ else
+ {
+ Debug.LogError(task.Exception);
+ callback(TransactionResult.Error);
+ }
+ });
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(target), target, null);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Game/Assets/Scripts/Backend.cs.meta b/Game/Assets/Scripts/Backend.cs.meta
new file mode 100644
index 0000000..da91118
--- /dev/null
+++ b/Game/Assets/Scripts/Backend.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d4abe93509ef91f47a2e59dd474a7137
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Game/Assets/Scripts/GameManager.cs b/Game/Assets/Scripts/GameManager.cs
new file mode 100644
index 0000000..6c1f18e
--- /dev/null
+++ b/Game/Assets/Scripts/GameManager.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+///
+/// singleton for a single source of truth game state and flow management
+///
+public class GameManager : MonoBehaviour
+{
+ ///
+ /// singleton pattern: define instance field for accessing the singleton elsewhere
+ ///
+ public static GameManager Instance;
+
+ ///
+ /// list of callbacks to call when the local player data changes
+ ///
+ private readonly List> _onLocalPlayerDataChangeCallbacks = new();
+
+ ///
+ /// the local player data object for storing player data
+ ///
+ private LocalPlayerData _data;
+
+ ///
+ /// backend object for handling communication with the firebase backend
+ ///
+ public Backend Backend;
+
+ ///
+ /// read-only property for accessing the local player data outside this class
+ ///
+ public LocalPlayerData Data => _data;
+
+ ///
+ /// enforces singleton behaviour; sets doesn't destroy on load and checks for multiple instances
+ ///
+ private void Awake()
+ {
+ // check if instance hasn't been set yet
+ if (Instance == null)
+ {
+ Debug.Log("awake as singleton instance, setting self as the forever-alive instance");
+ Instance = this;
+ DontDestroyOnLoad(gameObject);
+ }
+ // check if instance is already set and it's not this instance
+ else if (Instance != null && Instance != this)
+ {
+ Debug.Log("awake as non-singleton instance, destroying self");
+ Destroy(gameObject);
+ }
+ }
+
+ ///
+ /// start modifying state
+ ///
+ private void Start()
+ {
+ Debug.Log("GameManager starts here");
+ _data.LoadFromTheWorld(FireLocalPlayerDataChangeCallbacks);
+ }
+
+ ///
+ /// initialise variables and ui elements
+ ///
+ private void OnEnable()
+ {
+ // load the local player data and refresh the ui
+ _data = new LocalPlayerData();
+
+ Backend = new Backend();
+ Backend.Initialise(status => { Debug.Log("initialised backend"); });
+
+ // register a callback to refresh the ui when the player signs in
+ Backend.RegisterOnSignInCallback(_ =>
+ {
+ Debug.Log("sign in callback, refreshing GameManager-controlled SideView UI");
+ _data.LoadFromTheWorld(FireLocalPlayerDataChangeCallbacks);
+ });
+
+ Backend.RegisterOnConnectionStatusChangedCallback(status => { Debug.Log($"post-fcStatus change: {status}"); });
+ }
+
+ ///
+ /// called when the application is quitting, saves the local player data
+ ///
+ private void OnApplicationQuit()
+ {
+ Debug.Log("running deferred cleanup/save functions");
+ Backend.Cleanup();
+ _data.SaveToTheWorld();
+ }
+
+ ///
+ /// function to register a callback to be called when the local player data changes
+ ///
+ /// callback function that takes a LocalPlayerData object
+ public void RegisterOnLocalPlayerDataChangeCallback(Action callback)
+ {
+ _onLocalPlayerDataChangeCallbacks.Add(callback);
+ Debug.Log($"registering LocalPlayerDataChangeCallback ({_onLocalPlayerDataChangeCallbacks.Count})");
+ }
+
+ ///
+ /// function to fire all local player data change callbacks
+ ///
+ public void FireLocalPlayerDataChangeCallbacks(LocalPlayerData data)
+ {
+ Debug.Log($"firing LocalPlayerDataChangeCallbacks ({_onLocalPlayerDataChangeCallbacks.Count})");
+ foreach (var callback in _onLocalPlayerDataChangeCallbacks)
+ try
+ {
+ callback.Invoke(data);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"error invoking LocalPlayerDataChangeCallback: {e.Message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Game/Assets/Scripts/GameManager.cs.meta b/Game/Assets/Scripts/GameManager.cs.meta
new file mode 100644
index 0000000..b2de2ba
--- /dev/null
+++ b/Game/Assets/Scripts/GameManager.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: deaef88acf07406db5e0c354472ae40c
+timeCreated: 1732641033
\ No newline at end of file
diff --git a/Game/Assets/Scripts/Leaderboard.cs b/Game/Assets/Scripts/Leaderboard.cs
new file mode 100644
index 0000000..eee26d5
--- /dev/null
+++ b/Game/Assets/Scripts/Leaderboard.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+///
+/// class that loads leaderboard data and displays it in the UI
+///
+public class Leaderboard : MonoBehaviour
+{
+ ///
+ /// maximum number of entries to display in the leaderboard
+ ///
+ public const int MaxEntries = 50;
+
+ // ///
+ // /// register callbacks
+ // ///
+ // private void OnEnable() {}
+}
+
+public readonly struct LeaderboardEntry
+{
+ public readonly string Username;
+
+ public LeaderboardEntry(Dictionary data)
+ {
+ if (!data.ContainsKey("username") || data["username"] is not string username)
+ throw new ArgumentException("data['username'] not found or invalid");
+
+ Username = username;
+ }
+}
\ No newline at end of file
diff --git a/Game/Assets/Scripts/Leaderboard.cs.meta b/Game/Assets/Scripts/Leaderboard.cs.meta
new file mode 100644
index 0000000..8e2edbb
--- /dev/null
+++ b/Game/Assets/Scripts/Leaderboard.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 827c6e446cfa4da886052fe3934bdc1f
+timeCreated: 1732641719
\ No newline at end of file
diff --git a/Game/Assets/Scripts/LocalPlayerData.cs b/Game/Assets/Scripts/LocalPlayerData.cs
new file mode 100644
index 0000000..eb04ef1
--- /dev/null
+++ b/Game/Assets/Scripts/LocalPlayerData.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+///
+/// local player data structure/class
+///
+public class LocalPlayerData
+{
+ ///
+ /// last known email used
+ ///
+ public string LastKnownEmail = "";
+
+ ///
+ /// last known username used
+ ///
+ public string LastKnownUsername = "Guest";
+
+ ///
+ /// loads player data from player prefs and database
+ ///
+ public void LoadFromTheWorld(Action callback)
+ {
+ // load user data, possibly from the backend
+ var possibleUser = GameManager.Instance.Backend.GetUser();
+ var currentKnownEmail = string.Empty;
+ var currentKnownUsername = string.Empty;
+ if (possibleUser != null)
+ {
+ currentKnownEmail = possibleUser.Email;
+ currentKnownUsername = GameManager.Instance.Backend.GetUsername();
+ }
+
+ var lastStoredEmail = PlayerPrefs.GetString("LastKnownEmail", "");
+ var lastStoredUsername = PlayerPrefs.GetString("LastKnownUsername", "Guest");
+ LastKnownEmail = string.IsNullOrEmpty(currentKnownEmail) ? lastStoredEmail : currentKnownEmail;
+ LastKnownUsername = string.IsNullOrEmpty(currentKnownUsername) ? lastStoredUsername : currentKnownUsername;
+
+ Debug.Log($"loaded lpdata from the local world ({LastKnownUsername} <{LastKnownEmail}>");
+ callback(this);
+
+ // TODO: potentially load data from the backend
+ }
+
+ ///
+ /// saves player data to player prefs
+ ///
+ public void SaveToTheWorld()
+ {
+ PlayerPrefs.SetString("LastKnownEmail", LastKnownEmail);
+ PlayerPrefs.SetString("LastKnownUsername", LastKnownUsername);
+ // online scores should have already saved in the backend
+ Debug.Log("saved lpdata to player prefs");
+ }
+
+ // ///
+ // /// safely get a float value from a dictionary
+ // ///
+ // /// the dictionary to get the value from
+ // /// the key to get the value from
+ // /// the float value
+ // /// thrown if the key is not found, or the value is not a valid float
+ // public static float GetFloatyKey(Dictionary data, string key)
+ // {
+ // if (!data.TryGetValue(key, out var possibleFloat)) throw new ArgumentException($"{key} not found");
+ // return possibleFloat switch
+ // {
+ // double f => (float)f,
+ // long l => l,
+ // _ => throw new ArgumentException($"data['{key}'] not a valid float")
+ // };
+ // }
+
+ public struct Score
+ {
+ ///
+ /// timestamp of the score
+ ///
+ public DateTime Timestamp;
+
+ ///
+ /// constructor for the score struct
+ ///
+ /// timestamp of the score
+ public Score(DateTime timestamp = new())
+ {
+ Timestamp = timestamp;
+ }
+
+ ///
+ /// dict-based constructor for the score struct
+ ///
+ /// dictionary of the score data
+ /// thrown if the dictionary is malformed or missing data
+ public Score(Dictionary data)
+ {
+ // try to safely construct the score from a backend-provided dictionary
+ // for each value, if it's not found, or not a valid value, throw an exception
+
+ if (!data.ContainsKey("timestamp") || data["timestamp"] is not long timestamp)
+ throw new ArgumentException("data['timestamp'] not found or invalid");
+
+ Timestamp = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
+ }
+
+ ///
+ /// converts the score struct to a dictionary safe for the backend
+ ///
+ ///
+ public Dictionary ToDictionary(string userId = "")
+ {
+ var dict = new Dictionary
+ {
+ { "timestamp", new DateTimeOffset(Timestamp).ToUnixTimeSeconds() }
+ };
+
+ if (!string.IsNullOrEmpty(userId)) dict.Add("userId", userId);
+
+ return dict;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Game/Assets/Scripts/LocalPlayerData.cs.meta b/Game/Assets/Scripts/LocalPlayerData.cs.meta
new file mode 100644
index 0000000..8aed690
--- /dev/null
+++ b/Game/Assets/Scripts/LocalPlayerData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 51a8ca9c59474177bbfaab42d698cd3c
+timeCreated: 1732641111
\ No newline at end of file