Key takeaways
- Define each currency as a ScriptableObject, keep balances as long values in a plain C# wallet, and raise one BalanceChanged event that every UI label listens to.
- PlayerPrefs and local files are unencrypted and editable, so they are fine for offline soft currency but never the source of truth for currency bought with money or earned from offers.
- For hard currency, your server owns a ledger keyed by unique references, offerwall rewards arrive there by signed postback, and the game only reloads and displays the server balance.
How do you build a currency system in Unity?
A Unity currency system has three parts: a definition for each currency (a ScriptableObject with its ID, name, icon and limits), a wallet class that holds balances, validates every earn and spend, and raises an event so your UI updates, and a save layer that decides where each balance lives. Coins a player earns offline can live in a local save file. Anything bought with real money or earned from offers must live on your server, and the game only displays what the server says.
This guide builds all three in plain C#, then connects the wallet to a backend and to offerwall rewards. We compile-checked every C# sample against the Unity APIs it uses, and it targets Unity 2022.3 LTS and Unity 6, the versions the Sharklio Unity plugin supports. If you are still designing the economy itself (how many currencies, what they buy, how fast players earn), read how to design a virtual economy first.
Soft currency, hard currency, and who owns each
Most games end up with two kinds of currency. The kind decides where the balance is stored, and that is the most important design decision in the whole system:
| Soft currency (coins) | Hard currency (gems) | |
|---|---|---|
| How players get it | Playing: levels, quests, daily login | Real-money purchases, offerwall rewards, events |
| Source of truth | The device (local save) | Your server (database) |
| Who can add to it | The game client | Only your server |
| If a player edits the save | They cheat themselves in a single-player game | Not possible: the server ignores the client |
| Works offline | Yes | Read-only (last known balance) |
| Reversals | Not needed | Needed: purchases get refunded, offers get reversed |
The rule behind the table: if a balance has real-world cost or value to anyone, the client must not be able to create it. Editing a save to get a million coins in an offline puzzle game hurts nobody; editing it to get gems that cost you money, or that show up in a leaderboard or a trade, does.
Unity points the same way for its own services. Its Economy documentation says the Economy service stopped accepting new project sign-ups on September 8, 2026, and recommends that new projects build “a server-authoritative setup” with Cloud Code, Cloud Save and Remote Config (checked on October 22, 2026). The design below works with that stack or any backend of your own.
Step 1: define currencies with ScriptableObjects
Unity’s ScriptableObject reference describes it as a class for objects that live independently of GameObjects and a way to centralize data that scenes and assets can share. That is exactly what a currency definition is: one asset per currency, referenced by your wallet, shop items and UI labels, so the name and icon are set in one place.
using UnityEngine;
[CreateAssetMenu(fileName = "NewCurrency", menuName = "Economy/Currency")]
public class CurrencyDefinition : ScriptableObject
{
[SerializeField] private string id = "coins";
[SerializeField] private string displayName = "Coins";
[SerializeField] private Sprite icon;
[SerializeField] private long startingBalance = 0;
[SerializeField] private long maxBalance = 999_999_999;
[SerializeField] private bool serverAuthoritative = false;
public string Id => id;
public string DisplayName => displayName;
public Sprite Icon => icon;
public long StartingBalance => startingBalance;
public long MaxBalance => maxBalance;
public bool ServerAuthoritative => serverAuthoritative;
}
Create one asset per currency with Assets, Create, Economy, Currency, for example Coins and Gems. Tick Server Authoritative on Gems. Three choices in this class matter more than they look:
- Balances are
long, neverfloat. Microsoft’s C# reference givesfloata precision of about 6 to 9 digits, and nofloatcan represent 0.1 exactly. A whole-number type cannot drift. If a currency needs fractions, store the smallest unit (for example thousandths) as along. - The ID is a stable string. It is what you save and what your server sends, so never change it after launch.
- A maximum balance. It stops overflow bugs and absurd values from a broken reward table.
Treat these assets as read-only at runtime. A ScriptableObject is a good home for definitions, not for the player’s balance. Unity’s ScriptableObject manual page explains why: in the Editor you can save data to these assets in Play mode (so test runs quietly change your asset), while a standalone Player can only read them.
Step 2: a wallet service with events
The wallet is a plain C# class, not a MonoBehaviour, so you can unit test it without a scene. It owns the balances, validates every change, and raises one BalanceChanged event that your UI listens to:
using System;
using System.Collections.Generic;
public enum WalletResult { Ok, InvalidAmount, UnknownCurrency, NotEnough, OverMax, ServerOnly }
public class Wallet
{
private readonly Dictionary<string, CurrencyDefinition> _currencies = new Dictionary<string, CurrencyDefinition>();
private readonly Dictionary<string, long> _balances = new Dictionary<string, long>();
public event Action<CurrencyDefinition, long, long> BalanceChanged;
public Wallet(IEnumerable<CurrencyDefinition> currencies)
{
foreach (CurrencyDefinition c in currencies)
{
_currencies[c.Id] = c;
_balances[c.Id] = c.StartingBalance;
}
}
public IEnumerable<CurrencyDefinition> Currencies => _currencies.Values;
public long Get(string currencyId)
{
return _balances.TryGetValue(currencyId, out long balance) ? balance : 0;
}
public bool CanAfford(string currencyId, long price)
{
return price > 0 && Get(currencyId) >= price;
}
public WalletResult TryEarn(string currencyId, long amount)
{
WalletResult check = Validate(currencyId, amount, out CurrencyDefinition c);
if (check != WalletResult.Ok) return check;
long current = _balances[currencyId];
if (amount > c.MaxBalance - current) return WalletResult.OverMax;
Apply(c, current + amount);
return WalletResult.Ok;
}
public WalletResult TrySpend(string currencyId, long price)
{
WalletResult check = Validate(currencyId, price, out CurrencyDefinition c);
if (check != WalletResult.Ok) return check;
long current = _balances[currencyId];
if (current < price) return WalletResult.NotEnough;
Apply(c, current - price);
return WalletResult.Ok;
}
// For loading a save file and for balances that come from your server.
public void Overwrite(string currencyId, long balance)
{
if (_currencies.TryGetValue(currencyId, out CurrencyDefinition c))
{
Apply(c, Math.Min(balance, c.MaxBalance));
}
}
private WalletResult Validate(string currencyId, long amount, out CurrencyDefinition c)
{
if (!_currencies.TryGetValue(currencyId, out c)) return WalletResult.UnknownCurrency;
if (amount <= 0) return WalletResult.InvalidAmount;
if (c.ServerAuthoritative) return WalletResult.ServerOnly;
return WalletResult.Ok;
}
private void Apply(CurrencyDefinition c, long newBalance)
{
long old = _balances[c.Id];
if (old == newBalance) return;
_balances[c.Id] = newBalance;
BalanceChanged?.Invoke(c, newBalance, newBalance - old);
}
}
What the validation covers:
- Zero and negative amounts are rejected. Without this check,
TrySpend(-500)would add 500 coins, which is one of the most common currency bugs. - Unknown currency IDs are rejected instead of silently creating a new balance from a typo.
- The maximum is checked without overflow.
amount > MaxBalance - currentcannot overflow the waycurrent + amount > MaxBalancecan. - Server-authoritative currencies cannot be earned or spent locally.
TryEarnandTrySpendreturnServerOnlyfor gems, and the only way their balance changes isOverwritewith a value from your server.
The event carries the currency, the new balance and the change, so a label can show the number and an animation can show “+250”. Create the wallet once at startup and expose it:
using UnityEngine;
[DefaultExecutionOrder(-100)]
public class GameEconomy : MonoBehaviour
{
[SerializeField] private CurrencyDefinition[] currencies;
public static Wallet Wallet { get; private set; }
void Awake()
{
Wallet = new Wallet(currencies);
WalletSave.Load(Wallet);
}
void OnApplicationPause(bool paused)
{
if (paused) WalletSave.Save(Wallet);
}
void OnApplicationQuit()
{
WalletSave.Save(Wallet);
}
}
DefaultExecutionOrder(-100) makes this Awake run before the OnEnable of your labels. Put the component in your first scene and keep it alive between scenes (with DontDestroyOnLoad or a bootstrap scene).
Updating the UI from the event
A label subscribes when it is enabled and unsubscribes when it is disabled, so a closed shop panel does not keep listening:
using TMPro;
using UnityEngine;
public class CurrencyLabel : MonoBehaviour
{
[SerializeField] private CurrencyDefinition currency;
[SerializeField] private TMP_Text label;
void OnEnable()
{
GameEconomy.Wallet.BalanceChanged += OnBalanceChanged;
label.text = GameEconomy.Wallet.Get(currency.Id).ToString("N0");
}
void OnDisable()
{
GameEconomy.Wallet.BalanceChanged -= OnBalanceChanged;
}
private void OnBalanceChanged(CurrencyDefinition changed, long balance, long delta)
{
if (changed.Id == currency.Id) label.text = balance.ToString("N0");
}
}
Always pair += with -=. The wallet outlives your scenes, so a label that was destroyed without unsubscribing still receives the event and throws an error the next time the balance changes.
Step 3: earning and spending with validation
Gameplay code earns soft currency with one call, for example GameEconomy.Wallet.TryEarn("coins", 50) when a level ends. Spending checks the result before it hands anything over:
using UnityEngine;
public class ShopItem : MonoBehaviour
{
[SerializeField] private CurrencyDefinition priceCurrency;
[SerializeField] private long price = 250;
[SerializeField] private string itemId = "speed_boots";
public void Buy()
{
WalletResult result = GameEconomy.Wallet.TrySpend(priceCurrency.Id, price);
switch (result)
{
case WalletResult.Ok:
Inventory.Add(itemId);
break;
case WalletResult.NotEnough:
Debug.Log("Not enough " + priceCurrency.DisplayName);
break;
case WalletResult.ServerOnly:
Debug.LogWarning(priceCurrency.DisplayName + " is spent through your server.");
break;
default:
Debug.LogWarning("Purchase failed: " + result);
break;
}
}
}
The order matters: take the currency first, then grant the item, and only when TrySpend returned Ok. If you grant first and the spend fails, the player gets the item for free.
Keep prices and rewards in data, not in code
Put the price on the shop item (or in a price table asset) and the reward amounts in a reward table, never as numbers inside gameplay scripts. You can then rebalance from one place, and later move the tables to remote configuration without touching the wallet.
Step 4: saving balances, and why PlayerPrefs is not secure
PlayerPrefs is the first thing many tutorials reach for, and Unity’s own PlayerPrefs reference warns against it for anything sensitive: “Unity stores PlayerPrefs in a local registry, without encryption. Don’t use PlayerPrefs data to store sensitive data.” On Windows the values sit in the registry and on Android in an XML file, where a registry editor or a rooted phone can change them.
Encrypting or signing the save does not fix this, because the key ships inside your build and can be extracted. It only slows casual editing. So use the local save for what it is good at, offline soft currency, and do not save server-authoritative balances locally at all. A small JSON file in Application.persistentDataPath, which Unity’s persistentDataPath reference describes as the directory for data you want to keep between runs, is enough:
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public static class WalletSave
{
[Serializable]
private class Entry
{
public string currency = "";
public long amount;
}
[Serializable]
private class SaveData
{
public int version = 1;
public List<Entry> balances = new List<Entry>();
}
private static string FilePath => Path.Combine(Application.persistentDataPath, "wallet.json");
public static void Save(Wallet wallet)
{
SaveData data = new SaveData();
foreach (CurrencyDefinition c in wallet.Currencies)
{
if (c.ServerAuthoritative) continue;
data.balances.Add(new Entry { currency = c.Id, amount = wallet.Get(c.Id) });
}
string tmp = FilePath + ".tmp";
File.WriteAllText(tmp, JsonUtility.ToJson(data));
if (File.Exists(FilePath)) File.Replace(tmp, FilePath, null);
else File.Move(tmp, FilePath);
}
public static void Load(Wallet wallet)
{
if (!File.Exists(FilePath)) return;
SaveData data;
try
{
data = JsonUtility.FromJson<SaveData>(File.ReadAllText(FilePath));
}
catch (Exception e)
{
Debug.LogWarning("Wallet save unreadable, starting fresh: " + e.Message);
return;
}
if (data == null || data.balances == null) return;
foreach (CurrencyDefinition c in wallet.Currencies)
{
if (c.ServerAuthoritative) continue;
Entry e = data.balances.Find(x => x.currency == c.Id);
if (e != null) wallet.Overwrite(c.Id, Math.Max(0, e.amount));
}
}
}
- It writes to a temporary file first and then replaces the old save, so a crash halfway through a write does not leave a corrupt file.
- It skips server-authoritative currencies on save and on load, so a forged gems entry in the file is ignored.
- It has a version number, so you can migrate the format later.
- It saves on pause. Unity’s OnApplicationQuit reference says iOS apps suspend rather than quit, so
OnApplicationQuitis not called there, and the operating system can close a mobile app without it.OnApplicationPause(true)inGameEconomycovers that case.
On WebGL, persistentDataPath maps to browser storage, so test saving there separately.
Never store these on the device as the source of truth
Currency bought with real money, currency earned from offers or rewarded ads, anything players can trade or gift to each other, and anything that ranks players against each other. For all of these, the device shows a copy and the server owns the number.
Step 5: sync a server-authoritative balance
For gems, the server keeps a ledger: one row per change, with a unique reference so the same change can never be applied twice. A PostgreSQL version:
CREATE TABLE wallet_ledger (
id BIGSERIAL PRIMARY KEY,
player_id TEXT NOT NULL,
currency TEXT NOT NULL,
delta BIGINT NOT NULL,
source TEXT NOT NULL,
source_ref TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source, source_ref)
);
CREATE TABLE wallet_balance (
player_id TEXT NOT NULL,
currency TEXT NOT NULL,
amount BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (player_id, currency)
);
source says where a change came from (shop, iap, offer_credit, offer_reversal, support) and source_ref is its unique ID: the shop request ID, the store receipt ID, or the offerwall transaction ID. The ledger is your audit trail; wallet_balance is the fast copy the game reads.
Your server needs two endpoints for the game. GET /wallet returns the signed-in player’s balances as {"balances":[{"currency":"gems","amount":450}]}. POST /wallet/spend takes an item ID and a request ID and, in one database transaction:
- Looks up the price of the item on the server. Never accept a price from the client.
- Inserts a ledger row with source
shopand the request ID. If that row already exists, this is a retry: return the current balances and charge nothing. - Runs
UPDATE wallet_balance SET amount = amount - :price WHERE player_id = :player AND currency = 'gems' AND amount >= :price. If no row changed, the player cannot afford it: roll back and answer 409 with the current balances. - Grants the item and returns the new balances.
The client side is a component that loads balances and asks the server to spend:
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class WalletSync : MonoBehaviour
{
[SerializeField] private string walletUrl = "https://api.yourgame.example/wallet";
[SerializeField] private string spendUrl = "https://api.yourgame.example/wallet/spend";
[Serializable]
private class BalanceEntry
{
public string currency = "";
public long amount;
}
[Serializable]
private class WalletResponse
{
public BalanceEntry[] balances = new BalanceEntry[0];
}
private bool _refreshing;
public void Refresh()
{
if (!_refreshing) StartCoroutine(RefreshRoutine());
}
private IEnumerator RefreshRoutine()
{
_refreshing = true;
using (UnityWebRequest req = UnityWebRequest.Get(walletUrl))
{
req.SetRequestHeader("Authorization", "Bearer " + PlayerSession.Token);
req.timeout = 15;
yield return req.SendWebRequest();
_refreshing = false;
if (req.result == UnityWebRequest.Result.Success)
{
ApplyServerBalances(req.downloadHandler.text);
}
}
}
// requestId: create one per tap with Guid.NewGuid().ToString("N") and reuse it on retries.
public IEnumerator SpendOnServer(string itemId, string requestId, Action<bool> done)
{
WWWForm form = new WWWForm();
form.AddField("item_id", itemId);
form.AddField("request_id", requestId);
using (UnityWebRequest req = UnityWebRequest.Post(spendUrl, form))
{
req.SetRequestHeader("Authorization", "Bearer " + PlayerSession.Token);
req.timeout = 15;
yield return req.SendWebRequest();
bool ok = req.result == UnityWebRequest.Result.Success;
if (ok || req.responseCode == 409)
{
ApplyServerBalances(req.downloadHandler.text);
}
done(ok);
}
}
private void ApplyServerBalances(string json)
{
WalletResponse data = null;
try
{
data = JsonUtility.FromJson<WalletResponse>(json);
}
catch (ArgumentException)
{
}
if (data == null || data.balances == null) return;
foreach (BalanceEntry e in data.balances)
{
GameEconomy.Wallet.Overwrite(e.currency, e.amount);
}
}
}
PlayerSession.Token stands for your own sign-in. The server takes the player ID from that sign-in, never from the request body. Every answer from the server goes through Overwrite, so the same BalanceChanged event updates the same labels whether coins changed locally or gems changed on the server. If a request fails, the game keeps the last known balance; it never guesses.
Reuse the request ID when you retry a spend. That is what makes a retry after a timeout safe: the server sees the same ID and does not charge twice. The same idea protects your reward credits, as covered in how to prevent duplicate reward credits.
Step 6: plug in offerwall earnings
An offerwall lets players earn hard currency by completing offers, such as installing an app and reaching a level, and you earn for each completed offer. With a server-authoritative wallet, it needs no wallet changes:
- The player taps Earn. The game asks your server for a session token, and your server creates it with your API key.
- The game opens the offerwall with the Sharklio Unity plugin.
- When an offer is credited, Sharklio sends a signed postback to your server. Your server verifies it and writes a ledger row.
- When the player closes the wall or returns to the game,
WalletSync.Refresh()loads the new balance, and the labels update through the same event.
Install the plugin from the Sharklio registry in Packages/manifest.json:
{
"scopedRegistries": [
{
"name": "Sharklio",
"url": "https://sharklio.com/upm",
"scopes": ["com.sharklio"]
}
],
"dependencies": {
"com.sharklio.offerwall": "1.0.0"
}
}
Call Sharklio.Init("YOUR_APP_ID") once, and Sharklio.Open(token) with a fresh token from your server each time the player taps Earn. A token works once and for 10 minutes. The token endpoint, the full Earn button component and the platform requirements are in the Unity SDK docs, and the integration choices (plugin, system browser or WebView) are compared in how to add an offerwall to a Unity game. The only new code for your wallet is the refresh:
using SharklioOfferwall;
using UnityEngine;
public class OfferwallRewards : MonoBehaviour
{
[SerializeField] private WalletSync walletSync;
void OnEnable()
{
Sharklio.OnClosed += walletSync.Refresh;
}
void OnDisable()
{
Sharklio.OnClosed -= walletSync.Refresh;
}
void OnApplicationPause(bool paused)
{
if (!paused) walletSync.Refresh();
}
}
On the server, the postback handler maps directly onto the ledger:
| Postback | Ledger row | Rule |
|---|---|---|
status=1 (credited) | source offer_credit, source_ref = transaction ID, delta = +reward | Once per transaction ID; the unique key rejects repeats |
status=2 (reversed) | source offer_reversal, same transaction ID, delta = −reward | Only if an offer_credit row exists for that ID |
Verify the hash before you touch the database: it is an HMAC-SHA256 of the transaction ID, user ID, reward and status joined with colons, signed with your postback secret key. Answer with a 2xx status within 6 seconds. A C# version on ASP.NET Core is in offerwall postback in C# and ASP.NET Core, and the reasoning behind each check is in postback security.
Two settings in the Currency tab of your Sharklio app keep the numbers clean. Set Reward rounding to whole numbers, so {reward} arrives as an integer such as 250 that fits a long column. And set the conversion rate so a typical offer pays a sensible amount next to your shop prices; how to set your virtual currency exchange rate walks through that, and the virtual currency calculator does the math.
When a reversed reward was already spent
A reversal can arrive after the player spent the gems. Decide your rule before launch and put it in your terms. The ledger above lets the balance go below zero, and Overwrite accepts a negative value from the server, so the label shows it and CanAfford returns false until the player earns it back. The other common choice is to block withdrawals or trades until the balance recovers.
Testing checklist
- Wallet unit tests: earn, spend, spending more than the balance, zero and negative amounts, an unknown ID, the maximum, and a
ServerOnlyresult for gems. - Save tests: kill the app right after earning coins and reopen it; edit the save file to add gems and confirm they are ignored; delete the file and confirm a clean start.
- Sync tests: turn off the network during a spend, then retry with the same request ID and confirm the server charged once.
- Offerwall tests: in Integration status on your app’s Integration tab, send a test postback with status 1 for your test player and check that the balance rises once after
Refresh. Test transaction IDs start withtest_. The Testing docs list the common errors. - Real devices: the plugin opens the wall on Android and iOS only; in the Editor,
Sharklio.IsSupportedis false.
Paying your Unity currency through Sharklio
Sharklio is a pay-per-result affiliate network. Your game shows its offerwall through the Unity plugin, players earn your own currency with the name, icon, conversion rate, user split and rounding you set per app, and your server receives a signed postback for every credit and every reversal, which is exactly what a server-authoritative wallet needs. Offers shown depend on the player’s country and device, and players must be at least 16. Publisher applications are reviewed by hand: read how to get approved as a publisher, or see how the wall works.
Frequently asked questions
How do you make coins in Unity?
Create a ScriptableObject that defines the coin currency, a wallet class that stores the balance as a long and raises an event on every change, and call TryEarn when the player collects coins. A UI label subscribes to the event to show the new total.
Is PlayerPrefs safe for storing currency?
No. Unity’s documentation says PlayerPrefs is stored without encryption and should not hold sensitive data. It is fine for settings, not for currency that has value.
Should I use int, long or float for currency in Unity?
Use long (or int if balances stay small). Never use float, which loses precision and cannot represent values such as 0.1 exactly. For fractional currencies, store the smallest unit as a whole number.
Can I encrypt the save file to stop cheating?
Encryption only slows casual editing, because the key ships inside your build. For any currency bought with money or earned from offers, keep the balance on your server and treat the device copy as display only.
Do I need a server for a Unity virtual currency?
Not for soft currency in an offline single-player game. You do need one for real-money purchases, offerwall rewards, trading or leaderboards, because only a server can verify where currency came from.
How do offerwall rewards reach the player’s wallet in Unity?
The offerwall sends a signed server-to-server postback to your backend, which verifies it and adds the reward to the player’s ledger. The game then reloads the balance from your server when the player returns.