← All guides

Mock a REST API for Godot β€” HTTPRequest, await and a real leaderboard against a live URL

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:

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.)

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

2. Fetch and parse in GDScript

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.

3. Writes are real β€” POST, read it back, DELETE

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.

4. A working leaderboard in ~20 lines

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.

5. Test what happens when the server is slow or down

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.

6. Web exports (itch.io) actually work

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.

7. Honest comparison β€” when you've outgrown a mock

JSON in res://Local json-serverGame backend (SilentWolf, LootLocker, Nakama, PlayFab…)Mockbird
SetupnoneNode install + script per machineaccount, SDK/plugin, per-service concepts0–2 curls
Exercises real HTTP codeβœ—βœ” on one machineβœ”βœ” everywhere
Reachable from exported / web buildsn/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.

8. Useful extras

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.