ScoreDrop
Home

Unity SDK Guide

Complete documentation for ScoreDrop Unity Package


Table of Contents

1. Introduction

ScoreDrop is a simple, modern leaderboard API designed for indie game developers. This Unity package provides a complete, ready-to-use integration with the ScoreDrop service.

Features

2. Installation

2.1 Via Unity Asset Store

  1. Asset Store
  2. Asset Store support coming soon.

2.2 Via Unity Package Manager (UPM)

  1. Open Unity and go to Window > Package Manager
  2. Click the + button and select "Add package from git URL"
  3. Enter: https://github.com/Guardabarrancos/scoredrop-unity.git
  4. Click Add

2.3 Manual Installation

  1. Download the latest .unitypackage from the Releases page
  2. In Unity, go to Assets > Import Package > Custom Package
  3. Select the downloaded file and click Open
  4. Click Import in the Import dialog

Download the latest .unitypackage from the Releases page

Download SDK Unity Package (Dropbox)

2.4 Dependencies

This package requires TextMeshPro. If not already installed, Unity will prompt you to import it automatically.

3. Quick Start

3.1 Step 1: Add the Manager

Option A: Drag the ScoreDropManager prefab into your first scene.
Option B: Add the ScoreDropManager component to any existing GameObject.

3.2 Step 2: Configure API Keys

In the Inspector, set your:

3.3 Step 3: Add the UI

  1. Drag the ScoreDropCanvas prefab into your scene
  2. In the Inspector, assign the references:
    • Name HUD: Text that shows player name
    • Score HUD: Text that shows current score
    • Player Name Input: Input field for editing
    • Leaderboard Container: Where scores will appear
    • Score Entry Prefab: Your leaderboard entry prefab
    • All buttons (Submit, Refresh, Next, Prev, Edit Name, Add Score)

3.4 Step 4: Run the Demo

4. Component Reference

4.1 ScoreDropManager

The core singleton component that handles all API communication.

Public Methods

Method Description
GeneratePlayerId() Returns a new unique GUID for player identification
AddScore(string playerName, int score, string playerId, Action<AddScoreResponse> onSuccess, Action<string> onError) Sends a score to the leaderboard
GetLeaderboard(int limit, int page, Action<LeaderboardResponse> onSuccess, Action<string> onError) Retrieves leaderboard data

ScoreDropManager.cs

using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Text;
using System;

namespace ScoreDrop
{
    public class ScoreDropManager : MonoBehaviour
    {
        [Header("ScoreDrop Configuration")]
        [SerializeField] private string apiKey = "API Key"; // from your leaderboard
        [SerializeField] private string leaderboardId = "Leaderboard ID"; // UUID from your leaderboard
        [SerializeField] private string baseUrl = "https://leaderboard-game.vercel.app/api";
        
        // Singleton pattern
        public static ScoreDropManager Instance { get; private set; }

        private void Awake()
        {
            if (Instance == null)
            {
                Instance = this;
                DontDestroyOnLoad(gameObject);
            }
            else
            {
                Destroy(gameObject);
            }
        }

        /// 
        /// Genera un player_id único usando GUID
        /// 
        public string GeneratePlayerId()
        {
            return Guid.NewGuid().ToString();
        }

        /// 
        /// Envía un score al leaderboard
        /// 
        public void AddScore(string playerName, int score, string playerId, 
            System.Action<AddScoreResponse> onSuccess, 
            System.Action<string> onError)
        {
            StartCoroutine(AddScoreCoroutine(playerName, score, playerId, onSuccess, onError));
        }

        private IEnumerator AddScoreCoroutine(string playerName, int score, string playerId,
            System.Action<AddScoreResponse> onSuccess,
            System.Action<string> onError)
        {
            string url = $"{baseUrl}/add?key={apiKey}&player_id={playerId}&player={UnityWebRequest.EscapeURL(playerName)}&score={score}";
            
            using (UnityWebRequest request = UnityWebRequest.Get(url))
            {
                yield return request.SendWebRequest();

                if (request.result == UnityWebRequest.Result.Success)
                {
                    try
                    {
                        var response = JsonUtility.FromJson<AddScoreResponse>(request.downloadHandler.text);
                        if (response.success)
                        {
                            onSuccess?.Invoke(response);
                        }
                        else
                        {
                            onError?.Invoke("Unknown error");
                        }
                    }
                    catch
                    {
                        var error = JsonUtility.FromJson<ErrorResponse>(request.downloadHandler.text);
                        onError?.Invoke(error.error);
                    }
                }
                else
                {
                    onError?.Invoke(request.error);
                }
            }
        }

        /// 
        /// Obtiene el leaderboard actual
        /// 
        public void GetLeaderboard(int limit, int page, 
            System.Action<LeaderboardResponse> onSuccess,
            System.Action<string> onError)
        {
            StartCoroutine(GetLeaderboardCoroutine(limit, page, onSuccess, onError));
        }

        private IEnumerator GetLeaderboardCoroutine(int limit, int page,
            System.Action<LeaderboardResponse> onSuccess,
            System.Action<string> onError)
        {
            string url = $"{baseUrl}/top?key={apiKey}&limit={limit}&page={page}";
            
            using (UnityWebRequest request = UnityWebRequest.Get(url))
            {
                yield return request.SendWebRequest();

                if (request.result == UnityWebRequest.Result.Success)
                {
                    var response = JsonUtility.FromJson<LeaderboardResponse>(request.downloadHandler.text);
                    onSuccess?.Invoke(response);
                }
                else
                {
                    onError?.Invoke(request.error);
                }
            }
        }

        /// 
        /// Borra un score específico (solo planes de pago)
        /// 
        public void DeleteScore(string playerId,
            System.Action<AddScoreResponse> onSuccess,
            System.Action<string> onError)
        {
            StartCoroutine(DeleteScoreCoroutine(playerId, onSuccess, onError));
        }

        private IEnumerator DeleteScoreCoroutine(string playerId,
            System.Action<AddScoreResponse> onSuccess,
            System.Action<string> onError)
        {
            string url = $"{baseUrl}/delete?key={apiKey}&player_id={playerId}";
            
            using (UnityWebRequest request = UnityWebRequest.Get(url))
            {
                yield return request.SendWebRequest();

                if (request.result == UnityWebRequest.Result.Success)
                {
                    var response = JsonUtility.FromJson<AddScoreResponse>(request.downloadHandler.text);
                    onSuccess?.Invoke(response);
                }
                else
                {
                    onError?.Invoke(request.error);
                }
            }
        }
    }
}

4.2 ScoreDrop_Game

The main UI controller that manages player interaction.

ScoreDrop_Game.cs

using UnityEngine;
using TMPro;
using UnityEngine.UI;
using System.Collections;

namespace ScoreDrop
{
    /// <summary>
    /// Main UI controller for ScoreDrop leaderboard integration.
    /// - Add Score: Only increases local score (simulates gameplay)
    /// - Submit: Only sends current score to API
    /// - Edit Name: Changes player name (requires submit to save)
    /// </summary>
    public class ScoreDrop_Game : MonoBehaviour
    {
        [Header("Player HUD - Always Visible")]
        [Tooltip("Shows player name (always visible)")]
        [SerializeField] private TMP_Text nameHUD;
        
        [Tooltip("Shows player score (always visible)")]
        [SerializeField] private TMP_Text scoreHUD;
        
        [Header("Input Fields")]
        [Tooltip("Input field for player name (only visible when editing)")]
        [SerializeField] private TMP_InputField playerNameInput;
        
        [Header("Score Button")]
        [Tooltip("Button to add +10 score LOCALLY (simulates gaining points)")]
        [SerializeField] private Button addScoreButton;
        
        [Header("Display")]
        [Tooltip("Container where leaderboard entries will be instantiated")]
        [SerializeField] private Transform leaderboardContainer;
        
        [Tooltip("Prefab for each leaderboard entry (must have 3 TMP_Text: Rank, Player, Score)")]
        [SerializeField] private GameObject scoreEntryPrefab;
        
        [Tooltip("Shows current status (loading, page info, etc.)")]
        [SerializeField] private TMP_Text statusText;
        
        [Tooltip("Shows feedback messages after submitting scores")]
        [SerializeField] private TMP_Text feedbackText;
        
        [Header("Buttons")]
        [SerializeField] private Button submitButton;
        [SerializeField] private Button refreshButton;
        [SerializeField] private Button nextPageButton;
        [SerializeField] private Button prevPageButton;
        [SerializeField] private Button editNameButton;
        
        [Header("Settings")]
        [Tooltip("Number of scores to display per page")]
        [SerializeField] private int scoresPerPage = 10;
        
        [Header("UI Container")]
        [Tooltip("Container that holds the name input field (shown during editing)")]
        [SerializeField] private GameObject nameInputContainer;
        
        [Header("Default Values")]
        [Tooltip("Default score for new players (courtesy points)")]
        [SerializeField] private int defaultScore = 100;
        
        // =========================================================
        // PRIVATE VARIABLES
        // =========================================================
        private int currentPage = 1;
        private bool isSubmitting = false;
        private bool isEditingName = false;
        
        private string playerId;
        private string originalNameBeforeEdit;
        private int currentPlayerScore = 0;      // Local score (can be higher than submitted)
        private int lastSubmittedScore = 0;       // Last score successfully submitted
        private bool hasSavedScore = false;
        
        // PlayerPrefs keys
        private const string PLAYER_ID_KEY = "ScoreDrop_PlayerID";
        private const string PLAYER_NAME_KEY = "ScoreDrop_PlayerName";
        private const string PLAYER_SCORE_KEY = "ScoreDrop_PlayerScore";
        
        // =========================================================
        // INITIALIZATION
        // =========================================================
        private IEnumerator Start()
        {
            yield return new WaitUntil(() => ScoreDropManager.Instance != null);
            
            LoadOrCreatePlayerId();
            LoadSavedData();
            SetupButtonListeners();
            SetupDefaultName();
            UpdateHUDs();
            UpdateUIVisibility();
            
            // Initial leaderboard load
            RefreshLeaderboard();
        }
        
        private void LoadSavedData()
        {
            // Load saved score (last submitted score)
            lastSubmittedScore = PlayerPrefs.GetInt(PLAYER_SCORE_KEY, 0);
            currentPlayerScore = lastSubmittedScore; // Start with submitted score
            hasSavedScore = lastSubmittedScore > 0;
        }
        
        private void SetupButtonListeners()
        {
            if (submitButton != null)
                submitButton.onClick.AddListener(OnSubmitScore);
                
            if (refreshButton != null)
                refreshButton.onClick.AddListener(RefreshLeaderboard);
                
            if (nextPageButton != null)
                nextPageButton.onClick.AddListener(NextPage);
                
            if (prevPageButton != null)
                prevPageButton.onClick.AddListener(PrevPage);
                
            if (editNameButton != null)
                editNameButton.onClick.AddListener(OnEditName);
                
            if (addScoreButton != null)
                addScoreButton.onClick.AddListener(OnAddScoreLocally);
        }
        
        private void SetupDefaultName()
        {
            if (!PlayerPrefs.HasKey(PLAYER_NAME_KEY))
            {
                string defaultName = "Player" + Random.Range(10000, 99999).ToString();
                PlayerPrefs.SetString(PLAYER_NAME_KEY, defaultName);
                PlayerPrefs.Save();
            }
            
            playerNameInput.text = PlayerPrefs.GetString(PLAYER_NAME_KEY);
        }
        
        private void LoadOrCreatePlayerId()
        {
            if (PlayerPrefs.HasKey(PLAYER_ID_KEY))
            {
                playerId = PlayerPrefs.GetString(PLAYER_ID_KEY);
                Debug.Log($"[ScoreDrop] Player ID loaded: {playerId}");
            }
            else
            {
                playerId = ScoreDropManager.Instance.GeneratePlayerId();
                PlayerPrefs.SetString(PLAYER_ID_KEY, playerId);
                PlayerPrefs.Save();
                Debug.Log($"[ScoreDrop] New Player ID created: {playerId}");
            }
        }
        
        private void UpdateHUDs()
        {
            if (nameHUD != null)
                nameHUD.text = PlayerPrefs.GetString(PLAYER_NAME_KEY, "Player");
                
            if (scoreHUD != null)
                scoreHUD.text = currentPlayerScore.ToString();
        }
        
        private void UpdateUIVisibility()
        {
            // HUDs always visible
            if (nameHUD != null) nameHUD.gameObject.SetActive(true);
            if (scoreHUD != null) scoreHUD.gameObject.SetActive(true);
            
            // Name input container only during editing
            if (nameInputContainer != null)
                nameInputContainer.SetActive(isEditingName);
        }
        
        // =========================================================
        // BUTTON HANDLERS
        // =========================================================
        
        /// <summary>
        /// ONLY adds score locally - does NOT submit to API
        /// Simulates gameplay - player earns points
        /// </summary>
        private void OnAddScoreLocally()
        {
            currentPlayerScore += 10;
            UpdateHUDs();
            SetFeedback($"Score +10! Current: {currentPlayerScore} (Submit to save)", Color.white);
        }
        
        private void OnEditName()
        {
            isEditingName = true;
            originalNameBeforeEdit = playerNameInput.text;
            
            UpdateUIVisibility();
            playerNameInput.Select();
            playerNameInput.ActivateInputField();
            
            SetFeedback("Edit your name and click SUBMIT to save", Color.white);
        }
        
        /// <summary>
        /// ONLY submits current score to API
        /// Does NOT modify local score
        /// </summary>
        private void OnSubmitScore()
        {
            if (isSubmitting)
            {
                SetFeedback("Please wait...", Color.yellow);
                return;
            }
            
            // Get current player name (from input if editing, otherwise saved)
            string playerName = GetCurrentPlayerName();
            
            isSubmitting = true;
            SetStatus("Submitting...", Color.white);
            ClearFeedback();
            
            ScoreDropManager.Instance.AddScore(
                playerName, 
                currentPlayerScore, 
                playerId,
                OnSubmitSuccess,
                (error) => OnSubmitError(error, playerName)
            );
        }
        
        private string GetCurrentPlayerName()
        {
            if (isEditingName && !string.IsNullOrEmpty(playerNameInput.text))
            {
                return playerNameInput.text;
            }
            return PlayerPrefs.GetString(PLAYER_NAME_KEY, "Player");
        }
        
        private void RefreshLeaderboard()
        {
            SetStatus("Loading...", Color.white);
            ClearFeedback();
            
            ScoreDropManager.Instance.GetLeaderboard(
                scoresPerPage, 
                currentPage,
                OnLeaderboardLoaded,
                OnError
            );
        }
        
        // =========================================================
        // API RESPONSE HANDLERS
        // =========================================================
        
        private void OnSubmitSuccess(AddScoreResponse response)
        {
            isSubmitting = false;
            
            if (response.success)
            {
                // =========================================================
                // HANDLE NAME CHANGE
                // =========================================================
                if (isEditingName)
                {
                    PlayerPrefs.SetString(PLAYER_NAME_KEY, playerNameInput.text);
                    PlayerPrefs.Save();
                    isEditingName = false;
                    
                    if (nameHUD != null)
                        nameHUD.text = playerNameInput.text;
                    
                    UpdateUIVisibility();
                }
                
                // =========================================================
                // HANDLE SCORE SUBMISSION FEEDBACK
                // =========================================================
                if (response.message.Contains("not updated"))
                {
                    // Score was lower than best - didn't enter leaderboard
                    SetFeedback($"Score not improved. Your best is still {lastSubmittedScore}. Keep playing!", Color.yellow);
                }
                else if (response.message.Contains("replaced"))
                {
                    // New score entered leaderboard and replaced someone
                    lastSubmittedScore = currentPlayerScore;
                    PlayerPrefs.SetInt(PLAYER_SCORE_KEY, lastSubmittedScore);
                    PlayerPrefs.Save();
                    
                    SetFeedback($"NEW RECORD! You beat {response.replaced_player}'s score of {response.replaced_score}!", new Color(1, 0.5f, 0, 1));
                }
                else if (response.message.Contains("updated"))
                {
                    // New personal best
                    lastSubmittedScore = currentPlayerScore;
                    PlayerPrefs.SetInt(PLAYER_SCORE_KEY, lastSubmittedScore);
                    PlayerPrefs.Save();
                    
                    SetFeedback($"New personal best: {currentPlayerScore} pts!", Color.green);
                }
                else if (response.message.Contains("added"))
                {
                    // First score submission
                    lastSubmittedScore = currentPlayerScore;
                    PlayerPrefs.SetInt(PLAYER_SCORE_KEY, lastSubmittedScore);
                    PlayerPrefs.Save();
                    
                    SetFeedback($"First score recorded: {currentPlayerScore} pts!", Color.green);
                }
                
                RefreshLeaderboard();
            }
        }
        
        private void OnLeaderboardLoaded(LeaderboardResponse response)
        {
            // Clear existing entries
            foreach (Transform child in leaderboardContainer)
            {
                Destroy(child.gameObject);
            }
            
            int playerRank = 0;
            int playerScore = 0;
            int startRank = (currentPage - 1) * scoresPerPage + 1;
            
            // Create new entries
            for (int i = 0; i < response.scores.Length; i++)
            {
                var score = response.scores[i];
                int rank = startRank + i;
                
                var entry = Instantiate(scoreEntryPrefab, leaderboardContainer);
                var texts = entry.GetComponentsInChildren<TMP_Text>();
                
                if (texts.Length >= 3)
                {
                    texts[0].text = rank.ToString();
                    texts[1].text = score.player;
                    texts[2].text = score.score.ToString();
                }
                
                // Track player's data
                if (score.player_id == playerId)
                {
                    playerRank = rank;
                    playerScore = score.score;
                    
                    // Update last submitted score if leaderboard shows higher
                    if (playerScore > lastSubmittedScore)
                    {
                        lastSubmittedScore = playerScore;
                        PlayerPrefs.SetInt(PLAYER_SCORE_KEY, lastSubmittedScore);
                        PlayerPrefs.Save();
                    }
                    
                    // Highlight player's entry
                    var image = entry.GetComponent<Image>();
                    if (image != null)
                    {
                        image.color = new Color(1, 1, 0, 0.2f);
                    }
                }
            }
            
            // Update pagination info
            int totalPages = Mathf.CeilToInt((float)response.total_scores / scoresPerPage);
            
            if (playerRank > 0)
            {
                SetStatus($"Page {currentPage} of {totalPages} · You're #{playerRank} with {playerScore} pts · Total: {response.total_scores} scores", Color.white);
            }
            else
            {
                SetStatus($"Page {currentPage} of {totalPages} · Total: {response.total_scores} scores", Color.white);
            }
            
            // Update pagination buttons
            if (prevPageButton != null)
                prevPageButton.interactable = currentPage > 1;
                
            if (nextPageButton != null)
                nextPageButton.interactable = currentPage < totalPages;
        }
        
        // =========================================================
        // PAGINATION
        // =========================================================
        private void NextPage()
        {
            currentPage++;
            RefreshLeaderboard();
            ClearFeedback();
        }
        
        private void PrevPage()
        {
            currentPage--;
            RefreshLeaderboard();
            ClearFeedback();
        }
        
        // =========================================================
        // ERROR HANDLING
        // =========================================================
        
        private void OnError(string error)
        {
            isSubmitting = false;
            
            if (error.Contains("403") || error.Contains("Forbidden"))
            {
                SetFeedback("Leaderboard full. Keep playing to beat the lowest score!", Color.yellow);
            }
            else
            {
                SetFeedback($"Error: {error}", Color.red);
            }
            
            SetStatus("Error loading", Color.red);
        }
        
        private void OnSubmitError(string error, string attemptedName)
        {
            isSubmitting = false;
            
            if (error.Contains("403") || error.Contains("Forbidden"))
            {
                SetFeedback("Leaderboard full. Your score isn't high enough to enter the top.", Color.yellow);
            }
            else
            {
                SetFeedback($"Error: {error}", Color.red);
            }
            
            // Restore name if edit failed
            if (isEditingName)
            {
                playerNameInput.text = originalNameBeforeEdit;
                isEditingName = false;
                UpdateUIVisibility();
                SetFeedback("Name restored to original", Color.yellow);
            }
            
            SetStatus("Error", Color.red);
        }
        
        // =========================================================
        // UI FEEDBACK HELPERS
        // =========================================================
        
        private void SetStatus(string message, Color color)
        {
            if (statusText != null)
            {
                statusText.text = message;
                statusText.color = color;
            }
        }
        
        private void SetFeedback(string message, Color color)
        {
            if (feedbackText != null)
            {
                feedbackText.text = message;
                feedbackText.color = color;
            }
            else
            {
                Debug.Log($"[ScoreDrop] {message}");
            }
        }
        
        private void ClearFeedback()
        {
            if (feedbackText != null)
            {
                feedbackText.text = "";
            }
        }
    }
}

5. Prefabs

5.1 ScoreDropManager Prefab

A singleton GameObject that should exist in your first scene. It will persist across scenes using DontDestroyOnLoad.

5.2 ScoreDropCanvas Prefab

Complete UI layout containing:

5.3 Score_Entry_Prefab

Template for individual leaderboard entries. Must contain 3 TextMeshPro texts for:

6. API Integration

6.1 How Scoring Works

  1. Local Score: Players accumulate points locally (simulate gameplay)
  2. Submit: When ready, click Submit to send to API
  3. API Decision: The API only updates if the score is higher than previous best
  4. Feedback: Clear messages for each outcome

6.2 Player Identification

6.3 Name Editing

6.4 API Response Messages

Message Meaning
"Score not updated (existing score is higher)" Score didn't improve
"Score updated (new score is higher)" New personal best
"Score added (replaced lowest score in top)" Entered leaderboard, replaced lowest
"Score added successfully" First score submission
"Name updated (score unchanged)" Only name changed

6.5 Response Models

using System;

namespace ScoreDrop
{
    [Serializable]
    public class ScoreEntry
    {
        public string player;
        public int score;
        public string player_id;
    }

    [Serializable]
    public class LeaderboardResponse
    {
        public string leaderboard;
        public string plan;
        public int page;
        public int limit;
        public int total_scores;
        public ScoreEntry[] scores;
    }

    [Serializable]
    public class AddScoreResponse
    {
        public bool success;
        public string message;
        public string plan;
        public int scores_used;
        public int scores_limit;
        public string replaced_player;
        public int replaced_score;
    }

    [Serializable]
    public class ErrorResponse
    {
        public string error;
    }
}

6.6 Package.json

{
    "name": "com.scoredrop.core",
    "version": "1.0.0",
    "displayName": "ScoreDrop Leaderboard",
    "description": "Simple and powerful leaderboard system for Unity games. Integrates with ScoreDrop API.",
    "unity": "2020.3",
    "unityRelease": "0f1",
    "documentationUrl": "https://leaderboard-game.vercel.app/docs_api.html",
    "changelogUrl": "https://leaderboard-game.vercel.app/changelog",
    "licensesUrl": "https://leaderboard-game.vercel.app/terms.html",
    "dependencies": {
        "com.unity.textmeshpro": "3.0.6"
    },
    "keywords": [
        "leaderboard",
        "score",
        "ranking",
        "api",
        "online",
        "multiplayer"
    ],
    "author": {
        "name": "ScoreDrop",
        "email": "Guardabarrancoestudioapp@gmail.com",
        "url": "https://leaderboard-game.vercel.app"
    },
    "samples": [
        {
            "displayName": "Demo Scene",
            "description": "Contains a complete example of ScoreDrop integration",
            "path": "Samples~/DemoScene"
        }
    ]
}

7. Troubleshooting

7.1 "Leaderboard full" message

Problem: You see "Leaderboard full. Your score isn't high enough to enter the top."
Solution: Your score needs to be higher than the current lowest score in the top. Keep playing to improve your score.

7.2 Name not saving

Problem: Edited name doesn't appear after submit.
Solution: Make sure you click Submit after editing. Name changes are only saved when you submit a score.

7.3 Player ID lost

Problem: Player appears as a new player with different ID.
Solution: PlayerPrefs may have been cleared. This is normal on first launch or after clearing app data.

7.4 Connection errors

Problem: "Error: Connection failed" messages.
Solution: Check your internet connection and verify that the API URL is correct in ScoreDropManager.

8. FAQ

Q: Do I need to generate player_id manually?
A: No! The package automatically generates and stores a unique ID for each player.

Q: Can players change their name?
A: Yes! Click Edit Name, change the name, and click Submit to save.

Q: What happens if I submit a lower score?
A: The API ignores it and shows "Score not improved" feedback. Your best score remains unchanged.

Q: Is my data safe?
A: Yes. Player data is stored locally in PlayerPrefs. API communication is over HTTPS.

Q: Can I customize the UI?
A: Absolutely! The prefabs are fully editable. Just make sure to keep the required references.

Q: Does it work on mobile?
A: Yes! ScoreDrop works on all platforms Unity supports (PC, Mac, Linux, iOS, Android, WebGL).

9. Support

Documentation

Contact

Contributing

Found a bug? Have a feature request? We welcome community contributions!

Special thanks to:
Made with for indie game developers — Fer88