diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs index 979f3cc..ac4c256 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs @@ -9,7 +9,7 @@ namespace TaloGameServices public class BaseAPI { // automatically updated with a pre-commit hook - private const string ClientVersion = "0.51.0"; + private const string ClientVersion = "0.52.0"; protected string baseUrl; diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs index 40f960a..2bb5735 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs @@ -9,14 +9,22 @@ public abstract class DebouncedAPI : BaseAPI where TOperation : Enum { private class DebouncedOperation { - public float nextUpdateTime; - public bool hasPending; + public float windowEndTime; + public bool windowOpen; + public bool hasTrailingCallQueued; + public bool isExecuting; } private readonly Dictionary operations = new(); protected DebouncedAPI(string service) : base(service) { } + private void OpenWindow(DebouncedOperation op) + { + op.windowOpen = true; + op.windowEndTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds; + } + protected void Debounce(TOperation operation) { if (!operations.ContainsKey(operation)) @@ -24,8 +32,29 @@ protected void Debounce(TOperation operation) operations[operation] = new DebouncedOperation(); } - operations[operation].nextUpdateTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds; - operations[operation].hasPending = true; + var op = operations[operation]; + + if (!op.windowOpen && !op.isExecuting) + { + // leading call: fire immediately and open the debounce window + op.hasTrailingCallQueued = false; + op.isExecuting = true; + OpenWindow(op); + + ExecuteDebouncedOperation(operation).ContinueWith((t) => { + op.isExecuting = false; + if (t.IsFaulted) + { + Debug.LogError(t.Exception); + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + else + { + // window open or request in-flight: queue a trailing call and extend the window + op.hasTrailingCallQueued = true; + OpenWindow(op); + } } public async Task ProcessPendingUpdates() @@ -34,16 +63,45 @@ public async Task ProcessPendingUpdates() foreach (var kvp in operations) { - if (kvp.Value.hasPending && Time.realtimeSinceStartup >= kvp.Value.nextUpdateTime) + var op = kvp.Value; + var windowClosed = Time.realtimeSinceStartup >= op.windowEndTime; + if (windowClosed) { - keysToProcess.Add(kvp.Key); + if (op.hasTrailingCallQueued) + { + if (!op.isExecuting) + { + // window closed with a trailing call pending: execute it + keysToProcess.Add(kvp.Key); + } + else + { + // leading call still in-flight: delay trailing until it completes + OpenWindow(op); + } + } + else if (op.windowOpen) + { + // window closed with no trailing call: reset for the next leading call + op.windowOpen = false; + } } } foreach (var key in keysToProcess) { - operations[key].hasPending = false; - await ExecuteDebouncedOperation(key); + var op = operations[key]; + op.hasTrailingCallQueued = false; + op.isExecuting = true; + try + { + await ExecuteDebouncedOperation(key); + } + finally + { + op.isExecuting = false; + op.windowOpen = false; + } } } diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs index bafd90d..48f07ad 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs @@ -10,6 +10,7 @@ public class GetEntriesOptions { public int page = 0; public int aliasId = -1; + public string playerId = ""; public bool includeArchived = false; public string propKey = ""; public string propValue = ""; @@ -20,6 +21,7 @@ public string ToQueryString() { var query = new Dictionary { ["page"] = page.ToString() }; if (aliasId != -1) query["aliasId"] = aliasId.ToString(); + if (!string.IsNullOrEmpty(playerId)) query["playerId"] = playerId; if (includeArchived) query["withDeleted"] = "1"; if (!string.IsNullOrEmpty(propKey)) query["propKey"] = propKey; if (!string.IsNullOrEmpty(propValue)) query["propValue"] = propValue; @@ -32,7 +34,7 @@ public string ToQueryString() public class LeaderboardsAPI : BaseAPI { - private LeaderboardEntriesManager _entriesManager = new(); + private readonly LeaderboardEntriesManager _entriesManager = new(); public LeaderboardsAPI() : base("v1/leaderboards") { } @@ -65,6 +67,7 @@ public async Task GetEntries(string internalName, Ge return res; } + [Obsolete("Use GetEntries(string internalName, GetEntriesOptions options) with the aliasId or playerId option instead.")] public async Task GetEntriesForCurrentPlayer(string internalName, GetEntriesOptions options = null) { Talo.IdentityCheck(); @@ -86,7 +89,7 @@ public async Task GetEntries(string internalName, in }); } - [Obsolete("Use GetEntriesForCurrentPlayer(string internalName, GetEntriesOptions options) instead.")] + [Obsolete("Use GetEntries(string internalName, GetEntriesOptions options) with the aliasId or playerId option instead.")] public async Task GetEntriesForCurrentPlayer(string internalName, int page, bool includeArchived = false) { Talo.IdentityCheck(); diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayerAuthAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayerAuthAPI.cs index 0c342ca..1bc3b55 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayerAuthAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayerAuthAPI.cs @@ -6,7 +6,7 @@ namespace TaloGameServices { public class PlayerAuthAPI : BaseAPI { - private SessionManager _sessionManager = new(); + private readonly SessionManager _sessionManager = new(); public SessionManager SessionManager => _sessionManager; @@ -114,6 +114,19 @@ public async Task ChangeEmail(string currentPassword, string newEmail) await Call(uri, "POST", content); } + public async Task ChangeIdentifier(string currentPassword, string newIdentifier) + { + var uri = new Uri($"{baseUrl}/change_identifier"); + string content = JsonUtility.ToJson(new PlayerAuthChangeIdentifierRequest { + currentPassword = currentPassword, + newIdentifier = newIdentifier + }); + var json = await Call(uri, "POST", content); + + var res = JsonUtility.FromJson(json); + _sessionManager.HandleIdentifierUpdated(res); + } + public async Task ForgotPassword(string email) { var uri = new Uri($"{baseUrl}/forgot_password"); diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs index d7dab0b..c84d74b 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs @@ -22,7 +22,7 @@ public enum DebouncedOperation public event Action OnIdentificationFailed; public event Action OnIdentityCleared; - private readonly string offlineDataPath = Application.persistentDataPath + "/ta.bin"; + public static readonly string offlineDataPath = Application.persistentDataPath + "/ta.bin"; public PlayersAPI() : base("v1/players") { @@ -83,7 +83,7 @@ public async Task Identify(string service, string identifier) var res = JsonUtility.FromJson(json); var alias = res.alias; - WriteOfflineAlias(alias); + alias.WriteOfflineAlias(); return await HandleIdentifySuccess(alias, res.socketToken); } catch @@ -133,7 +133,7 @@ public async Task Update() var res = JsonUtility.FromJson(json); Talo.CurrentPlayer = res.player; - WriteOfflineAlias(Talo.CurrentAlias); + Talo.CurrentAlias.WriteOfflineAlias(); return Talo.CurrentPlayer; } @@ -191,13 +191,6 @@ private async Task IdentifyOffline(string service, string identifier) } } - private void WriteOfflineAlias(PlayerAlias alias) - { - if (!Talo.Settings.cachePlayerOnIdentify) return; - var content = JsonUtility.ToJson(alias); - Talo.Crypto.WriteFileContent(offlineDataPath, content); - } - private PlayerAlias GetOfflineAlias() { if (!Talo.Settings.cachePlayerOnIdentify || !File.Exists(offlineDataPath)) return null; diff --git a/Assets/Talo Game Services/Talo/Runtime/Entities/PlayerAlias.cs b/Assets/Talo Game Services/Talo/Runtime/Entities/PlayerAlias.cs index 41b5ee3..4a7e4a5 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Entities/PlayerAlias.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Entities/PlayerAlias.cs @@ -1,4 +1,6 @@ -namespace TaloGameServices +using UnityEngine; + +namespace TaloGameServices { [System.Serializable] public class PlayerAlias @@ -12,5 +14,16 @@ public bool MatchesIdentifyRequest(string service, string identifier) { return this.service == service && this.identifier == identifier; } + + public void WriteOfflineAlias() + { + if (!Talo.Settings.cachePlayerOnIdentify) + { + return; + } + + var content = JsonUtility.ToJson(this); + Talo.Crypto.WriteFileContent(PlayersAPI.offlineDataPath, content); + } } } diff --git a/Assets/Talo Game Services/Talo/Runtime/Requests/PlayerAuthChangeIdentifierRequest.cs b/Assets/Talo Game Services/Talo/Runtime/Requests/PlayerAuthChangeIdentifierRequest.cs new file mode 100644 index 0000000..84c8755 --- /dev/null +++ b/Assets/Talo Game Services/Talo/Runtime/Requests/PlayerAuthChangeIdentifierRequest.cs @@ -0,0 +1,9 @@ +namespace TaloGameServices +{ + [System.Serializable] + public class PlayerAuthChangeIdentifierRequest + { + public string currentPassword; + public string newIdentifier; + } +} diff --git a/Assets/Talo Game Services/Talo/Runtime/Requests/PlayerAuthChangeIdentifierRequest.cs.meta b/Assets/Talo Game Services/Talo/Runtime/Requests/PlayerAuthChangeIdentifierRequest.cs.meta new file mode 100644 index 0000000..9f9ddad --- /dev/null +++ b/Assets/Talo Game Services/Talo/Runtime/Requests/PlayerAuthChangeIdentifierRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 57cdb010475b8496f8a1f16fc88bb404 \ No newline at end of file diff --git a/Assets/Talo Game Services/Talo/Runtime/Responses/PlayerAuthChangeIdentifierResponse.cs b/Assets/Talo Game Services/Talo/Runtime/Responses/PlayerAuthChangeIdentifierResponse.cs new file mode 100644 index 0000000..be610d9 --- /dev/null +++ b/Assets/Talo Game Services/Talo/Runtime/Responses/PlayerAuthChangeIdentifierResponse.cs @@ -0,0 +1,8 @@ +namespace TaloGameServices +{ + [System.Serializable] + public class PlayerAuthChangeIdentifierResponse + { + public PlayerAlias alias; + } +} diff --git a/Assets/Talo Game Services/Talo/Runtime/Responses/PlayerAuthChangeIdentifierResponse.cs.meta b/Assets/Talo Game Services/Talo/Runtime/Responses/PlayerAuthChangeIdentifierResponse.cs.meta new file mode 100644 index 0000000..a265741 --- /dev/null +++ b/Assets/Talo Game Services/Talo/Runtime/Responses/PlayerAuthChangeIdentifierResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfb958912de184a41ac04d92e78ea63b \ No newline at end of file diff --git a/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs b/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs index a086cfa..ff5be5f 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs @@ -5,7 +5,7 @@ namespace TaloGameServices { public class LeaderboardEntriesManager { - private Dictionary> _currentEntries = new Dictionary>(); + private readonly Dictionary> _currentEntries = new(); public List GetEntries(string internalName) { @@ -16,7 +16,7 @@ public List GetEntries(string internalName) return _currentEntries[internalName]; } - public void UpsertEntry(string internalName, LeaderboardEntry entry, bool bumpPositions = false) + public void UpsertEntry(string internalName, LeaderboardEntry entryToUpsert, bool bumpPositions = false) { if (!_currentEntries.ContainsKey(internalName)) { @@ -26,18 +26,23 @@ public void UpsertEntry(string internalName, LeaderboardEntry entry, bool bumpPo var entries = _currentEntries[internalName]; // ensure there isn't an existing entry - entries.RemoveAll((e) => e.id == entry.id); + entries.RemoveAll((e) => e.id == entryToUpsert.id); - int insertPosition = FindInsertPosition(entries, entry); - entries.Insert(insertPosition, entry); + int insertPosition = FindInsertPosition(entries, entryToUpsert); + entries.Insert(insertPosition, entryToUpsert); if (bumpPositions) { - foreach (var e in entries) + // if we find a collision, bump subsequent entries down by 1 + int collisionIndex = entries.FindIndex((e) => e.id != entryToUpsert.id && e.position == entryToUpsert.position); + if (collisionIndex != -1) { - if (e.id != entry.id && e.position >= entry.position) + for (int i = collisionIndex; i < entries.Count; i++) { - e.position += 1; + if (entries[i].id != entryToUpsert.id) + { + entries[i].position += 1; + } } } } diff --git a/Assets/Talo Game Services/Talo/Runtime/Utils/PlayerAuthException.cs b/Assets/Talo Game Services/Talo/Runtime/Utils/PlayerAuthException.cs index 47e7f6d..9852771 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Utils/PlayerAuthException.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Utils/PlayerAuthException.cs @@ -14,7 +14,8 @@ public enum PlayerAuthErrorCode { NEW_EMAIL_MATCHES_CURRENT_EMAIL, PASSWORD_RESET_CODE_INVALID, VERIFICATION_EMAIL_REQUIRED, - INVALID_EMAIL + INVALID_EMAIL, + NEW_IDENTIFIER_MATCHES_CURRENT_IDENTIFIER } public class PlayerAuthException : Exception diff --git a/Assets/Talo Game Services/Talo/Runtime/Utils/SessionManager.cs b/Assets/Talo Game Services/Talo/Runtime/Utils/SessionManager.cs index 6c855d9..6ac0fab 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Utils/SessionManager.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Utils/SessionManager.cs @@ -15,10 +15,15 @@ public void HandleSessionCreated(PlayerAuthSessionResponse res) Talo.Socket.SetSocketToken(res.socketToken); } + private void SetIdentifierPlayerPref() + { + PlayerPrefs.SetString("TaloSessionIdentifier", Talo.CurrentAlias.identifier); + } + private void SaveSession(string sessionToken) { PlayerPrefs.SetString("TaloSessionToken", sessionToken); - PlayerPrefs.SetString("TaloSessionIdentifier", Talo.CurrentAlias.identifier); + SetIdentifierPlayerPref(); } public async Task ClearSession() @@ -42,5 +47,12 @@ public bool CheckForSession() { return !string.IsNullOrEmpty(GetSessionToken()); } + + public void HandleIdentifierUpdated(PlayerAuthChangeIdentifierResponse res) + { + Talo.CurrentAlias = res.alias; + Talo.CurrentAlias.WriteOfflineAlias(); + SetIdentifierPlayerPref(); + } } } diff --git a/Assets/Talo Game Services/Talo/Samples/AuthenticationDemo/Settings/Panel Settings.asset b/Assets/Talo Game Services/Talo/Samples/AuthenticationDemo/Settings/Panel Settings.asset index f0f27ab..2298b2f 100644 --- a/Assets/Talo Game Services/Talo/Samples/AuthenticationDemo/Settings/Panel Settings.asset +++ b/Assets/Talo Game Services/Talo/Samples/AuthenticationDemo/Settings/Panel Settings.asset @@ -41,6 +41,9 @@ MonoBehaviour: m_AtlasBlitShader: {fileID: 9101, guid: 0000000000000000f000000000000000, type: 0} m_RuntimeShader: {fileID: 9100, guid: 0000000000000000f000000000000000, type: 0} m_RuntimeWorldShader: {fileID: 9102, guid: 0000000000000000f000000000000000, type: 0} + m_SDFShader: {fileID: 19011, guid: 0000000000000000f000000000000000, type: 0} + m_BitmapShader: {fileID: 9001, guid: 0000000000000000f000000000000000, type: 0} + m_SpriteShader: {fileID: 19012, guid: 0000000000000000f000000000000000, type: 0} m_ICUDataAsset: {fileID: 0} forceGammaRendering: 0 textSettings: {fileID: 0} diff --git a/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs b/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs index 4fff1e0..72faed1 100644 --- a/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs +++ b/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs @@ -191,6 +191,35 @@ public void UpsertEntry_BumpPositions_OnlyBumpsAffectedEntries() Assert.AreEqual(3, entries[3].position); // bumped from 2 to 3 } + [Test] + public void UpsertEntry_BumpPositions_DoesNotBumpWhenPlayerImprovesButKeepsSamePosition() + { + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 1, leaderboardSortMode = "desc" }; + var entry3 = new LeaderboardEntry { id = 3, score = 60f, position = 2, leaderboardSortMode = "desc" }; + + manager.UpsertEntry("test", entry1); + manager.UpsertEntry("test", entry2); + manager.UpsertEntry("test", entry3); + + // entry1 improves their score but stays at position 0 - no one should be bumped + var updatedEntry1 = new LeaderboardEntry { id = 1, score = 110f, position = 0, leaderboardSortMode = "desc" }; + manager.UpsertEntry("test", updatedEntry1, bumpPositions: true); + + var entries = manager.GetEntries("test"); + + Assert.AreEqual(3, entries.Count); + + Assert.AreEqual(1, entries[0].id); + Assert.AreEqual(0, entries[0].position); // still first + + Assert.AreEqual(2, entries[1].id); + Assert.AreEqual(1, entries[1].position); // unchanged + + Assert.AreEqual(3, entries[2].id); + Assert.AreEqual(2, entries[2].position); // unchanged + } + [Test] public void UpsertEntry_NoBumpPositions_PreservesExistingPositions() { diff --git a/Assets/Talo Game Services/Talo/VERSION b/Assets/Talo Game Services/Talo/VERSION index c5d4cee..4f9b378 100644 --- a/Assets/Talo Game Services/Talo/VERSION +++ b/Assets/Talo Game Services/Talo/VERSION @@ -1 +1 @@ -0.51.0 +0.52.0 diff --git a/CLAUDE.md b/CLAUDE.md index 2e506f7..c88bd0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,8 @@ Events are batched and flushed on application quit/pause/focus loss. On WebGL, e ### Debouncing Player updates and save updates are debounced to prevent excessive API calls during rapid property changes. APIs that need debouncing inherit from `DebouncedAPI` (a generic base class) and define a `DebouncedOperation` enum for type-safe operation keys. The base class uses a dictionary to track multiple debounced operations independently. +The debounce is **leading and trailing**: the first call fires immediately (leading), and if further calls arrive during the debounce window they are coalesced into a single trailing call executed after the window closes. The window is defined by `debounceTimerSeconds` (default: 1s) and resets on each subsequent call. + To add debouncing to an API: 1. Define a public `enum DebouncedOperation` with your debounced operations 2. Inherit from `DebouncedAPI` @@ -56,7 +58,7 @@ To add debouncing to an API: 4. Implement `ExecuteDebouncedOperation(DebouncedOperation operation)` with a switch statement 5. The base class's `ProcessPendingUpdates()` is called by `TaloManager.Update()` every frame -Example: `PlayersAPI` defines `enum DebouncedOperation { Update }` and inherits from `DebouncedAPI`. When `Player.SetProp()` is called, it calls `Debounce(DebouncedOperation.Update)`, which queues the update to be executed after `debounceTimerSeconds` (default: 1s). Multiple property changes within the debounce window result in a single API call. +Example: `PlayersAPI` defines `enum DebouncedOperation { Update }` and inherits from `DebouncedAPI`. When `Player.SetProp()` is called, it calls `Debounce(DebouncedOperation.Update)`. The first call fires immediately; subsequent calls within the debounce window result in a single trailing API call at the end of the window. ## Common Development Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f73989a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +## Setup + +1. Clone the repository with submodules: + ```bash + git clone --recurse-submodules + # or after cloning: + git submodule update --init + ``` + +2. Open the project in **Unity 6000.0.59f2** (or later). + +## Project structure + +``` +Assets/Talo Game Services/Talo/ +├── Runtime/ +│ ├── APIs/ # One file per API (extend BaseAPI or DebouncedAPI) +│ ├── Entities/ # Data models (Player, GameSave, LeaderboardEntry, etc.) +│ ├── Requests/ # Request payload classes +│ ├── Responses/ # Response payload classes +│ ├── SocketRequests/ # WebSocket message types (outbound) +│ ├── SocketResponses/ # WebSocket message types (inbound) +│ ├── Utils/ # Internal managers and helpers +│ ├── Vendor/ # Third-party dependencies (WebSocket client) +│ ├── Talo.cs # Main static entry point +│ ├── TaloManager.cs # MonoBehaviour lifecycle manager +│ ├── TaloSocket.cs # WebSocket connection handler +│ └── TaloSettings.cs # Add new settings here +├── Tests/ # NUnit test suite +├── Samples/ # Demo scenes (Leaderboards, Saves, Auth, Chat, etc.) +``` + +## Code style + +Follow the patterns established in existing API and entity files: + +- All API classes inherit from `BaseAPI` (or `DebouncedAPI` when debouncing is needed) +- Use `async`/`await` for all network operations +- Request and response types live in their respective `Requests/` and `Responses/` folders + +## Testing your changes + +Tests use the **Unity Test Framework** (NUnit). Run them inside the editor via **Window > General > Test Runner**. + +Consider adding tests where relevant. + +## Submitting a PR + +- Keep PRs focused — one feature or fix per PR +- Target the `develop` branch +- Use the Playground sample scene (`Samples/Playground`) to test your changes interactively. diff --git a/README.md b/README.md index 3ce7dac..4df8c4a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ You'll need to init the submodules after cloning: `git submodule update --init`. - 🗣️ [Game feedback](https://trytalo.com/feedback): Collect and manage feedback from your players. - 🛡️ [Continuity](https://trytalo.com/continuity): Keep your data in-sync even when your players are offline. - 🔔 [Player presence](https://trytalo.com/players#presence): See if players are online and set custom statuses. +- 🤝 [Player relationships](https://trytalo.com/player-relationships): Easily add friends, followers and social systems to your game. ## Samples included with the package @@ -39,7 +40,8 @@ You'll need to init the submodules after cloning: `git submodule update --init`. - 🔒 **Authentication**: a registration/login flow, showing how to create player accounts and authenticate them. - 🎮 **Playground**: a text-based playground allowing you to test identifying, events, stats and leaderboards. - 💬 **Chat**: showing how to send messages between players in a chat room using channels. -- 🤝 **Channel storage**: showing how to store data that can be accessed by other players using channels. +- 📦 **Channel storage**: showing how to store data that can be accessed by other players using channels. +- 🤝 **Friends list**: a friends list UI with friend statuses, incoming/outgoing requests and player-to-player broadcasts. ## Links diff --git a/unity.slnx b/unity.slnx index 8e1c5af..033166a 100644 --- a/unity.slnx +++ b/unity.slnx @@ -1,7 +1,7 @@  - - + +