Your game needs a backend before the backend exists: a leaderboard, a news panel on the title screen, remote config, a daily-challenge feed. The usual Godot answers all hurt a little:
res:// β instant, but your HTTPRequest code, error handling and loading states never actually run;localhost: an exported build on a tester's machine can't reach it, and a web export on itch.io can't even try (browsers block plain-http localhost calls from an https page);The boring fix is a hosted mock API: a real https URL that answers editor, exported desktop build, mobile build and web export alike. Every GDScript snippet below was run verbatim in headless Godot 4.3 against the live endpoints on this page before publishing β they paste straight into a Node script. (Godot 4 syntax throughout; on 3.x the same ideas work with yield instead of await.)
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
HTTPRequest is a node: add it to the tree, fire request(), await the request_completed signal. The signal yields four values β result code, HTTP status, headers, body:
extends Node
func _ready() -> void:
var http := HTTPRequest.new()
add_child(http)
http.request("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3")
var res: Array = await http.request_completed
var code: int = res[1]
var products = JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8())
print("HTTP ", code, " β got ", products.size(), " products")
for p in products:
print(" #", p.id, " ", p.name, " $", p.price)
get_tree().quit()
Output, from a real run:
HTTP 200 β got 3 products
#1 Field Life Day Name Music $670.74
#2 Part Problem $600.41
#3 Night Team Fact Power Name $917.63
One HTTPRequest node handles one request at a time. For parallel calls, create one node per in-flight request (they're cheap) β or reuse a single node sequentially like the snippets here do.
Unlike JSONPlaceholder-style fake APIs, writes persist. Your "submit" code path gets a real 201, and the record is really there afterwards:
extends Node
const BASE := "https://mockbird.mockbird.workers.dev/m/demo"
func _ready() -> void:
var http := HTTPRequest.new()
add_child(http)
# CREATE β POST persists (this is not fake: you can GET it back)
var body := JSON.stringify({"name": "Health Potion", "price": 4.99, "category": "toys"})
http.request(BASE + "/products", ["Content-Type: application/json"], HTTPClient.METHOD_POST, body)
var res: Array = await http.request_completed
var created = JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8())
print("created id ", created.id, " (HTTP ", res[1], ")")
# READ it back β it's really there
http.request(BASE + "/products/" + str(created.id))
res = await http.request_completed
var back = JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8())
print("read back: ", back.name, " $", back.price)
# DELETE β clean up
http.request(BASE + "/products/" + str(created.id), [], HTTPClient.METHOD_DELETE)
res = await http.request_completed
print("deleted (HTTP ", res[1], ")")
get_tree().quit()
Real run: created id 31 (HTTP 201) β read back: Health Potion $4.99 β deleted (HTTP 200). PUT and PATCH work too, and everything is visible to teammates hitting the same URL from a browser or curl.
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:
extends Node
const BASE := "https://mockbird.mockbird.workers.dev/m/YOUR_ID"
@onready var http := HTTPRequest.new()
func _ready() -> void:
add_child(http)
await submit_score("new_champion", 999999)
var top: Array = await top_scores(10)
print("=== LEADERBOARD ===")
for i in top.size():
print(i + 1, ". ", top[i].player, " β ", top[i].score)
get_tree().quit()
func submit_score(player: String, score: int) -> void:
var body := JSON.stringify({"player": player, "score": score})
http.request(BASE + "/scores", ["Content-Type: application/json"], HTTPClient.METHOD_POST, body)
await http.request_completed
func top_scores(n: int) -> Array:
http.request(BASE + "/scores?sortBy=score&order=desc&limit=" + str(n))
var res: Array = await http.request_completed
return JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8())
Real output from the verification run:
=== LEADERBOARD ===
1. new_champion β 999999
2. ezrathomas51 β 979
3. lenatanaka50 β 955
β¦
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 is for managing the project, the mock URLs don't need it.
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 HTTPRequest.timeout set shorter, your actual timeout branch runs:
extends Node
func _ready() -> void:
var http := HTTPRequest.new()
add_child(http)
http.timeout = 1.5 # seconds β your game's real timeout setting
http.request("https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=3000")
var res: Array = await http.request_completed
if res[0] == HTTPRequest.RESULT_TIMEOUT:
print("timed out β show the 'server unreachable' UI, keep the game playable")
else:
print("result ", res[0], " HTTP ", res[1])
get_tree().quit()
Real run: it prints the timeout line. 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.
extends Node
func _ready() -> void:
var http := HTTPRequest.new()
add_child(http)
var key := str(randi()) # isolate this run's sequence
var url := "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1&mock_seq=500,500,200&mock_seq_key=" + key
for attempt in range(1, 6):
http.request(url)
var res: Array = await http.request_completed
var code: int = res[1]
print("attempt ", attempt, " β HTTP ", code)
if code < 400:
print("recovered after ", attempt, " attempts")
break
await get_tree().create_timer(0.5 * attempt).timeout # backoff
get_tree().quit()
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.
A Godot web export runs in the browser, so every HTTP call is subject to CORS and mixed-content rules: 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 web build. That's the quiet killer feature of a hosted mock for game jams: the jam build on itch.io and the editor hit the same URL.
| JSON in res:// | Local json-server | Game backend (SilentWolf, LootLocker, Nakama, PlayFabβ¦) | Mockbird | |
|---|---|---|---|---|
| Setup | none | Node install + script per machine | account, SDK/plugin, per-service concepts | 0β2 curls |
| Exercises real HTTP code | β | β on one machine | β | β everywhere |
| Reachable from exported / web 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 β SilentWolf and LootLocker have Godot-specific integrations, Nakama is open source and self-hostable, PlayFab/Firebase/Supabase are general-purpose. 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 is the whole title-screen news panel.?mock_snapshot=name), so integration tests don't flake when someone posts a new high score.Related: testing loading and error states, mock any custom endpoint, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.
Verification: every GDScript snippet on this page was run verbatim in headless Godot 4.3-stable (Linux) against production on 5 Sep 2026 β the printed outputs shown are from those runs. The leaderboard section ran against a real project created with the exact curls shown (since deleted). The demo POST/DELETE cleanup ran as printed. If a snippet here doesn't work in your Godot 4.x project, that's a bug: tell us.