Your game needs a backend before the backend exists: a leaderboard, cloud-style save slots, a news panel on the title screen, remote config for tuning. The usual Unity answers all hurt a little:
Resources/ or a ScriptableObject β instant, but your UnityWebRequest code, error handling and loading states never actually run;localhost: a build on a tester's phone can't reach it, and a WebGL build on itch.io can't even try (an https page can't call plain-http localhost, and WebGL enforces CORS);The boring fix is a hosted mock API: a real https URL that answers the editor, a device build, and a WebGL export alike. Every C# snippet below was executed verbatim against the live endpoints on this page before publishing β the printed outputs are from those runs (details in the verification note). Plain coroutines, no packages, no SDK.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL returns a top-level JSON array β [ {...}, {...} ] β and Unity's built-in JsonUtility famously cannot parse one: FromJson<T> throws ArgumentException: JSON must represent an object type. The workaround every Unity forum thread reinvents is the wrapper-string hack:
// the classic hack: wrap the array by hand, then parse the wrapper
string wrapped = "{\"items\":" + rawJson + "}";
You don't need it here. Add ?mock_envelope=items to any list URL and the server answers {"items":[...]} β already the shape JsonUtility wants. (Any key works: mock_envelope=data β {"data":[...]}; there's a full template syntax for fancier shapes, and a project-wide default if you never want to think about it again.) So fetching and parsing is just:
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class FetchProducts : MonoBehaviour
{
[System.Serializable] public class Product { public int id; public string name; public float price; }
[System.Serializable] public class ProductList { public Product[] items; }
IEnumerator Start()
{
// ?mock_envelope=items makes the server answer {"items":[...]} instead of a
// bare [...] array β which JsonUtility can't parse. No wrapper-string hacks.
string url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3&mock_envelope=items";
using (UnityWebRequest req = UnityWebRequest.Get(url))
{
yield return req.SendWebRequest();
ProductList list = JsonUtility.FromJson<ProductList>(req.downloadHandler.text);
Debug.Log($"HTTP {req.responseCode} β got {list.items.Length} products");
foreach (Product p in list.items)
Debug.Log($" #{p.id} {p.name} ${p.price}");
}
}
}
Output, from a real run:
HTTP 200 β got 3 products
#1 Stone Name Day Work Bridge $324.9
#2 Area Week Story Way $614.97
#3 Line Child Month Garden World $996.42
Prefer Newtonsoft (com.unity.nuget.newtonsoft-json)? Then top-level arrays are fine and you can skip the envelope. The rest of this page works identically β but staying on JsonUtility means zero packages, and the envelope trick is what makes that painless.
Two curls give you your own API with a scores collection (15 seeded rows so the screen isn't empty on first run):
curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "content-type: application/json" -d '{"name":"my-game","blank":true}'
# β {"id":"YOUR_ID", "adminKey":"YOUR_ADMIN_KEY", ...} β save both
curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/resources \
-H "x-admin-key: YOUR_ADMIN_KEY" -H "content-type: application/json" \
-d '{"name":"scores","fields":[{"name":"player","type":"username"},{"name":"score","type":"number"}],"seed":15}'
Sorting is a query param, so "top 10" is just a URL β no server code. Writes are real: the POST persists, and the submitted score shows up in the very next GET:
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class Leaderboard : MonoBehaviour
{
const string BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_ID";
[System.Serializable] public class Row { public int id; public string player; public int score; }
[System.Serializable] public class Top { public Row[] items; }
[System.Serializable] public class NewScore { public string player; public int score; }
IEnumerator Start()
{
yield return Submit("new_champion", 999999);
using (UnityWebRequest req = UnityWebRequest.Get(BASE + "/scores?sortBy=score&order=desc&limit=10&mock_envelope=items"))
{
yield return req.SendWebRequest();
Top top = JsonUtility.FromJson<Top>(req.downloadHandler.text);
Debug.Log("=== LEADERBOARD ===");
for (int i = 0; i < top.items.Length; i++)
Debug.Log($"{i + 1}. {top.items[i].player} β {top.items[i].score}");
}
}
IEnumerator Submit(string player, int score)
{
string json = JsonUtility.ToJson(new NewScore { player = player, score = score });
using (UnityWebRequest req = UnityWebRequest.Post(BASE + "/scores", json, "application/json"))
{
yield return req.SendWebRequest();
Debug.Log($"score submitted (HTTP {req.responseCode})");
}
}
}
Real output from the verification run:
score submitted (HTTP 201)
=== LEADERBOARD ===
1. new_champion β 999999
2. dmitricosta92 β 975
3. noahmensah32 β 763
β¦
Filters compose: ?score_gte=500, ?player_like=tom, ?_page=2&_limit=25 (with a real X-Total-Count header for "page 2 of 7" UI). Ship only the /m/YOUR_ID URL in the game β never the admin key; the key manages the project, the mock URLs don't need it.
UnityWebRequest.Post(url, json, "application/json") is the Unity 2022.2+ overload. On older versions the two-argument Post(url, string) form-encodes your JSON (a classic gotcha) β build the request by hand instead:
var req = new UnityWebRequest(url, "POST");
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
Add a saves collection (empty this time β slots are created by the game):
curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/resources \
-H "x-admin-key: YOUR_ADMIN_KEY" -H "content-type: application/json" \
-d '{"name":"saves","fields":[{"name":"player","type":"username"},{"name":"level","type":"number"},{"name":"gold","type":"number"}],"seed":0}'
PUT /saves/1 overwrites slot 1 if it exists and returns 404 if it doesn't β so "save" is a PUT with a one-line POST fallback, and "load" is a GET (a single record comes back as a plain object, which JsonUtility parses directly β no envelope needed):
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class CloudSave : MonoBehaviour
{
const string BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_ID";
[System.Serializable] public class SaveData { public string player; public int level; public int gold; }
IEnumerator Start()
{
yield return Save(1, new SaveData { player = "pilar", level = 4, gold = 250 });
// load it back β it really persisted
using (UnityWebRequest req = UnityWebRequest.Get(BASE + "/saves/1"))
{
yield return req.SendWebRequest();
SaveData loaded = JsonUtility.FromJson<SaveData>(req.downloadHandler.text);
Debug.Log($"loaded slot 1: level {loaded.level}, {loaded.gold} gold");
}
}
IEnumerator Save(int slot, SaveData data)
{
string json = JsonUtility.ToJson(data);
using (UnityWebRequest req = UnityWebRequest.Put(BASE + "/saves/" + slot, json))
{
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.responseCode == 404) // slot doesn't exist yet β create it
using (UnityWebRequest post = UnityWebRequest.Post(BASE + "/saves", json, "application/json"))
yield return post.SendWebRequest();
Debug.Log($"saved slot {slot}");
}
}
}
Real run: saved slot 1 β loaded slot 1: level 4, 250 gold. Your save data survives across sessions, machines and teammates β anyone on the team can inspect a bug report's save slot in a browser at /m/YOUR_ID/saves/1.
This is the part no static JSON file can do, and the reason to point your game at a mock even if the real backend exists. Add query params to any URL:
A real timeout β ?mock_delay=3000 holds the response for 3 s; with UnityWebRequest.timeout set shorter, your actual timeout branch runs:
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class TimeoutDrill : MonoBehaviour
{
IEnumerator Start()
{
// ?mock_delay=3000 holds the response for 3s; with timeout = 1 your
// real timeout branch runs β no airplane mode required.
using (UnityWebRequest req = UnityWebRequest.Get("https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=3000"))
{
req.timeout = 1; // seconds β your game's real setting
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.ConnectionError)
Debug.Log("timed out β show the 'server unreachable' panel, keep the game playable (" + req.error + ")");
else
Debug.Log($"unexpected: {req.result} HTTP {req.responseCode}");
}
}
}
Real run: it prints the timed-out line, with req.error == "Request timeout". A server error is one param β ?mock_status=500 (any code: 404, 429, 503β¦) β so the "couldn't load leaderboard" panel gets exercised before a player ever sees it by accident.
A deterministic outage for your retry loop β ?mock_seq=500,500,200 answers the 1st request with 500, the 2nd with 500, the 3rd normally. No flaky sleep-and-hope: the recovery is scripted.
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class RetryDrill : MonoBehaviour
{
IEnumerator Start()
{
string key = Random.Range(0, int.MaxValue).ToString(); // isolate this run's sequence
string url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1"
+ "&mock_seq=500,500,200&mock_seq_key=" + key;
for (int attempt = 1; attempt <= 5; attempt++)
{
using (UnityWebRequest req = UnityWebRequest.Get(url))
{
yield return req.SendWebRequest();
Debug.Log($"attempt {attempt} β HTTP {req.responseCode}");
if (req.responseCode < 400) { Debug.Log($"recovered after {attempt} attempts"); yield break; }
}
yield return new WaitForSeconds(0.5f * attempt); // backoff
}
}
}
Real run: 500 β 500 β 200, recovered after 3 attempts. There's also ?mock_jitter (random latency), ?mock_chaos=0.3 (random failure fraction) and ?mock_ratelimit=5 (a real 429 with Retry-After) β the full simulation-params guide covers them.
In a WebGL build, UnityWebRequest is the only HTTP door β System.Net.Http doesn't exist there β and every call goes through the browser, so CORS and mixed-content rules apply: an https page on itch.io cannot call http://localhost:3000, and it can only call servers that opt in with CORS headers. Every Mockbird endpoint answers with Access-Control-Allow-Origin: * on GET, POST, PUT, PATCH and DELETE β so the exact leaderboard code above ships unchanged in a WebGL jam build. The build on itch.io and the editor hit the same URL.
| JSON in Resources/ | Local json-server | Game backend (UGS, PlayFab, LootLocker, Nakamaβ¦) | Mockbird | |
|---|---|---|---|---|
| Setup | none | Node install + script per machine | account, SDK/package, per-service concepts | 0β2 curls |
| Exercises real UnityWebRequest code | β | β on one machine | β | β everywhere |
| Reachable from device / WebGL builds | n/a | β (localhost) | β | β (CORS open) |
| Inject timeouts / 500s / rate limits | β | β (write middleware) | β | one query param |
| Production leaderboards, auth, anti-cheat | β | β | β β this is their job | β β see below |
Be clear-eyed about the last row: a Mockbird project is a mock. Anyone with the URL can POST a 999999 score (the snippet above literally does), data is capped and resettable, and the JWT auth endpoints simulate login flows rather than secure anything. For a shipped game with real players, graduate to a real game-backend service β Unity Gaming Services is the first-party path, PlayFab and LootLocker are batteries-included, Nakama is open source and self-hostable. Use the mock for what it's for: building tonight, testing failure paths honestly, jam builds, and CI β a project takes 10,000 requests/day, which game development barely dents.
{"preset":"ecommerce"} (or blog/saas) instead of blank:true creates a multi-resource seeded API in one curl β or one click.GET /config/difficulty for the title-screen tuning file.posts resource + ?sortBy=date&order=desc&limit=5&mock_envelope=items is the whole title-screen news panel, JsonUtility-ready.?mock_snapshot=name), so Play-Mode tests don't flake when someone posts a new high score.Related: mock APIs for Unreal Engine, mock APIs for Godot, mock APIs for Roblox, mock APIs for C# / .NET, testing loading and error states, mock any custom endpoint, deterministic test data with snapshots, and free mock-API tools compared.
Verification: Unity itself can't run headless on our test box, so every C# snippet on this page was executed verbatim against production on 20 Sep 2026 through a behavior-faithful .NET 8 harness β UnityWebRequest backed by HttpClient (same overloads, timeout β ConnectionError + "Request timeout", HTTP β₯ 400 β ProtocolError), JsonUtility reimplemented with Unity's semantics (public fields only, throws on top-level arrays, ignores unknown members), and a coroutine runner for IEnumerator Start(). The printed outputs shown are from those runs; every URL and JSON shape was additionally verified with curl. The leaderboard and save-slot sections ran against a real project created with the exact curls shown (since deleted). If a snippet doesn't work in your Unity project, that's a bug: tell us.