Talk to REST APIs, leaderboards, analytics, login services, and other HTTP/WebSocket backends. All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.
Related skills: multiplayer-basics for real-time game networking with WebSocketMultiplayerPeer, save-load for persisting tokens and cached responses, web-export for CORS and browser limits, mobile-development for Android export permissions, multithreading for heavy response parsing off the main thread.
1. Picking the Tool
| Need | Use | Why |
|---|---|---|
| Ordinary REST call, file or image download | HTTPRequest node |
Handles connect, redirects, gzip, timeout; emits one signal when done |
| Streaming a response, custom keep-alive, many requests to one host | HTTPClient |
Low-level; you call poll() and drive the state machine yourself |
| Persistent two-way channel (chat, live events, lobby service) | WebSocketPeer |
Client socket; call poll() every frame |
| Real-time gameplay sync with RPCs | WebSocketMultiplayerPeer |
Plugs into MultiplayerAPI — see multiplayer-basics |
One HTTPRequest node runs one request at a time. A second request() while one is in flight returns ERR_BUSY. Create one node per concurrent request (Section 3) rather than queueing on a shared node.
2. A Single Request with await
request() only starts the request; its return value says whether it could be sent, not whether the server answered. The result arrives on request_completed(result, response_code, headers, body).
func fetch_motd() -> void:
var http := HTTPRequest.new()
http.timeout = 10.0 # Default 0.0 means wait forever.
add_child(http)
var err := http.request("https://api.example.com/v1/motd")
if err != OK:
push_error("Could not start request: %s" % error_string(err))
http.queue_free()
return
var res: Array = await http.request_completed
http.queue_free()
var result: int = res[0]
var code: int = res[1]
var body: PackedByteArray = res[3]
if result != HTTPRequest.RESULT_SUCCESS:
push_warning("Network failure (HTTPRequest.Result %d)" % result)
elif code != 200:
push_warning("Server answered HTTP %d" % code)
else:
print(body.get_string_from_utf8())
public async void FetchMotd()
{
var http = new HttpRequest { Timeout = 10.0 }; // Default 0.0 means wait forever.
AddChild(http);
Error err = http.Request("https://api.example.com/v1/motd");
if (err != Error.Ok)
{
GD.PushError($"Could not start request: {err}");
http.QueueFree();
return;
}
Variant[] res = await ToSignal(http, HttpRequest.SignalName.RequestCompleted);
http.QueueFree();
long result = res[0].AsInt64();
long code = res[1].AsInt64();
byte[] body = res[3].AsByteArray();
if (result != (long)HttpRequest.Result.Success)
GD.PushWarning($"Network failure (HttpRequest.Result {result})");
else if (code != 200)
GD.PushWarning($"Server answered HTTP {code}");
else
GD.Print(body.GetStringFromUtf8());
}
Check both values: result is the transport outcome (DNS, TLS, timeout — RESULT_CANT_RESOLVE, RESULT_TLS_HANDSHAKE_ERROR, RESULT_TIMEOUT, …), response_code is the HTTP status. A 404 or 500 arrives with RESULT_SUCCESS.
3. A Reusable JSON API Client (Autoload)
Wrap the boilerplate once. Register this as an autoload named Api; every call spawns its own HTTPRequest, so calls can overlap.
# api.gd — autoload "Api"
extends Node
const BASE_URL := "https://api.example.com/v1"
const TIMEOUT_SEC := 10.0
var auth_token: String = ""
## Returns {ok: bool, code: int, data: Variant, error: String}.
func request_json(path: String, method: HTTPClient.Method = HTTPClient.METHOD_GET, payload: Variant = null) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = TIMEOUT_SEC
add_child(http)
var headers := PackedStringArray(["Accept: application/json"])
var body := ""
if payload != null:
headers.append("Content-Type: application/json")
body = JSON.stringify(payload)
if not auth_token.is_empty():
headers.append("Authorization: Bearer " + auth_token)
var err := http.request(BASE_URL + path, headers, method, body)
if err != OK:
http.queue_free()
return {"ok": false, "code": 0, "data": null, "error": "request() failed: " + error_string(err)}
var res: Array = await http.request_completed
http.queue_free()
if res[0] != HTTPRequest.RESULT_SUCCESS:
return {"ok": false, "code": 0, "data": null, "error": "transport error %d" % res[0]}
var code: int = res[1]
var text := (res[3] as PackedByteArray).get_string_from_utf8()
var data: Variant = JSON.parse_string(text) if not text.is_empty() else null
return {"ok": code >= 200 and code < 300, "code": code, "data": data, "error": "" if code < 400 else text}
// Api.cs — autoload "Api"
using System.Collections.Generic;
using System.Threading.Tasks;
using Godot;
public partial class Api : Node
{
private const string BaseUrl = "https://api.example.com/v1";
private const double TimeoutSec = 10.0;
public string AuthToken { get; set; } = "";
public readonly record struct ApiResult(bool Ok, long Code, Variant Data, string Error);
public async Task<ApiResult> RequestJson(string path, HttpClient.Method method = HttpClient.Method.Get, Variant payload = default)
{
var http = new HttpRequest { Timeout = TimeoutSec };
AddChild(http);
var headers = new List<string> { "Accept: application/json" };
string body = "";
if (payload.VariantType != Variant.Type.Nil)
{
headers.Add("Content-Type: application/json");
body = Json.Stringify(payload);
}
if (AuthToken != "")
headers.Add("Authorization: Bearer " + AuthToken);
Error err = http.Request(BaseUrl + path, headers.ToArray(), method, body);
if (err != Error.Ok)
{
http.QueueFree();
return new ApiResult(false, 0, default, $"Request() failed: {err}");
}
Variant[] res = await ToSignal(http, HttpRequest.SignalName.RequestCompleted);
http.QueueFree();
if (res[0].AsInt64() != (long)HttpRequest.Result.Success)
return new ApiResult(false, 0, default, $"Transport error {res[0]}");
long code = res[1].AsInt64();
string text = res[3].AsByteArray().GetStringFromUtf8();
Variant data = text == "" ? default : Json.ParseString(text);
return new ApiResult(code is >= 200 and < 300, code, data, code < 400 ? "" : text);
}
}
Call it from anywhere:
var res := await Api.request_json("/scores", HTTPClient.METHOD_POST, {"name": "Ada", "score": 1200})
if not res.ok:
show_error(res.error)
var api = GetNode<Api>("/root/Api");
var res = await api.RequestJson("/scores", HttpClient.Method.Post, new Godot.Collections.Dictionary { { "name", "Ada" }, { "score", 1200 } });
if (!res.Ok)
ShowError(res.Error);
Retries with exponential backoff, request cancellation on scene change, and an auth refresh flow: references/rest-client-patterns.md.
4. JSON Pitfalls
- Every JSON number parses as
float.{"id": 5}gives5.0. Cast before using it as an index, enum, or dictionary key:int(data["id"])in GDScript,data["id"].AsInt32()in C#.==compares5.0 == 5as true, butmatchpatterns are type-strict, and a dictionary keyed by5has no entry for5.0. JSON.parse_string()returnsnullon bad input and logs an error. When you need the line and message, use an instance:var json := JSON.new(); if json.parse(text) != OK: print(json.get_error_message(), json.get_error_line()).- Validate shape before indexing. Check
data is Dictionary and data.has("items")— a server error page in HTML parses tonull, and an empty array is stillok. - A stringify → parse round trip turns ints into floats (
stringifyitself writes5, the parse brings back5.0), andJSON.stringify(obj)only works on Variant types — convert custom objects or Resources to a Dictionary first.
5. Downloads, Uploads, and Images
| Task | How |
|---|---|
| Save straight to disk | Set http.download_file = "user://cache/pack.zip" before request(); the body is written there, not returned |
| Progress bar | Poll get_downloaded_bytes() against get_body_size() (-1 when the server sends no length, and always -1 on Web) |
| Cap the size | body_size_limit (bytes) → RESULT_BODY_SIZE_LIMIT_EXCEEDED |
| Binary upload | request_raw(url, headers, HTTPClient.METHOD_PUT, bytes) with a Content-Type header |
| Show a remote image | Image.new().load_png_from_buffer(body) (or _jpg_, _webp_), then ImageTexture.create_from_image(image) |
Image, form-encoded, and multipart recipes with C#: references/downloads-and-uploads.md.
6. TLS and Secrets
- HTTPS works out of the box against the bundled CA list. For a self-signed or private CA, load the
.crtand passTLSOptions.client(cert)viahttp.set_tls_options().TLSOptions.client_unsafe()skips validation — never ship it. - Anything in the export is readable. API keys in scripts,
project.godot, or an encrypted PCK can be pulled out by a determined player. Keep privileged keys (payment, admin, third-party paid APIs) on a server you control and have the game call that server. - Per-player tokens (session or refresh tokens) can live in
user://; treat them as revocable, not secret. - Send credentials in headers or a POST body, never as GET query parameters — URLs end up in logs.
- TLS 1.2 is supported everywhere and TLS 1.3 from Godot 4.4; on 4.3 a TLS 1.3-only server fails with
RESULT_TLS_HANDSHAKE_ERROR, as does any server limited to TLS 1.1 or older.
7. Platform Notes
| Platform | Gotcha |
|---|---|
| Android | Enable the INTERNET permission in the export preset, or every request fails |
| Web | Requests go through the browser: the server must send CORS headers (Access-Control-Allow-Origin), and plain http:// from an https:// page is blocked as mixed content. See web-export |
| Dedicated server | Same API; run requests from the server so keys never reach clients — see dedicated-server |
8. WebSocket Client
WebSocketPeer is a plain client socket. Nothing happens unless you call poll() regularly.
extends Node
var socket := WebSocketPeer.new()
func _ready() -> void:
var err := socket.connect_to_url("wss://events.example.com/live")
if err != OK:
push_error("WebSocket connect failed: %s" % error_string(err))
set_process(false)
func _process(_delta: float) -> void:
socket.poll()
match socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
while socket.get_available_packet_count() > 0:
var msg: Variant = JSON.parse_string(socket.get_packet().get_string_from_utf8())
if msg is Dictionary:
_handle_event(msg)
WebSocketPeer.STATE_CLOSED:
print("Closed: %d %s" % [socket.get_close_code(), socket.get_close_reason()])
set_process(false)
func send_event(data: Dictionary) -> void:
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
socket.send_text(JSON.stringify(data))
using Godot;
public partial class LiveEvents : Node
{
private readonly WebSocketPeer _socket = new();
public override void _Ready()
{
Error err = _socket.ConnectToUrl("wss://events.example.com/live");
if (err != Error.Ok)
{
GD.PushError($"WebSocket connect failed: {err}");
SetProcess(false);
}
}
public override void _Process(double delta)
{
_socket.Poll();
switch (_socket.GetReadyState())
{
case WebSocketPeer.State.Open:
while (_socket.GetAvailablePacketCount() > 0)
{
Variant msg = Json.ParseString(_socket.GetPacket().GetStringFromUtf8());
if (msg.VariantType == Variant.Type.Dictionary)
HandleEvent(msg.AsGodotDictionary());
}
break;
case WebSocketPeer.State.Closed:
GD.Print($"Closed: {_socket.GetCloseCode()} {_socket.GetCloseReason()}");
SetProcess(false);
break;
}
}
public void SendEvent(Godot.Collections.Dictionary data)
{
if (_socket.GetReadyState() == WebSocketPeer.State.Open)
_socket.SendText(Json.Stringify(data));
}
}
- Use
wss://with the server's real hostname (not an IP) so the certificate matches; Web exports require it onhttpspages. handshake_headers(for an auth header) is ignored on Web — authenticate with the first message after connecting. If a URL parameter is unavoidable, use a short-lived single-use ticket, never the long-lived token (URLs end up in logs).- Godot 4.4+:
heartbeat_intervalsends automatic ping frames to keep idle connections alive (no effect on Web). - Reconnect with backoff on
STATE_CLOSEDrather than spinningconnect_to_url()every frame.
9. Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
request() returns ERR_BUSY (44) |
Reused one HTTPRequest for overlapping calls |
One node per in-flight request |
request() returns ERR_UNCONFIGURED |
Node not in the tree yet | add_child() before request() |
| Request hangs forever | timeout left at 0.0 |
Set timeout (5–15 s) on API calls; big downloads need a stall check instead (timeout covers the whole transfer) |
| Works in editor, fails on Android | Missing INTERNET permission | Tick it in the export preset |
| Works on desktop, fails on Web | CORS or mixed content | Fix server headers; use HTTPS/WSS |
id is 5.0, not 5 |
JSON numbers are floats | Cast with int() |
Body empty, file appears in user:// |
download_file was set |
Clear it for in-memory requests |
| Request never completes after a scene change | The HTTPRequest was a child of a node that got freed, so the coroutine never resumes |
Parent requests to an autoload (Section 3); don't touch freed nodes after the await |
10. Checklist
- One
HTTPRequestper concurrent request; each isqueue_free()d after completion -
timeoutset on API calls; large downloads use a stall check or cancel button instead - Both the transport
resultand the HTTPresponse_codeare checked - JSON numbers cast to
intwhere integers are expected; parsed data shape validated - No privileged API keys in the client build; a server proxies paid or admin APIs
- Credentials sent in headers or POST bodies over HTTPS, never in GET URLs
-
TLSOptions.client_unsafe()not used outside local testing - Android preset has the INTERNET permission
- Web build tested against the real server (CORS headers, HTTPS/WSS only)
- WebSocket
poll()called every frame; reconnects back off - Failures surface to the player (retry button, offline message) instead of failing silently