Godot / other engines
Everything in these docs works no matter what built your game — window.DavidnetSDK is injected into the index.html file itself, at the HTML level, not tied to any particular engine or language. A Godot project exported
to HTML5/WebAssembly, a Unity WebGL build, or anything else that ends up as an index.html + JS/WASM bundle gets the exact same window.DavidnetSDK object. By the time your game's own code starts running, the SDK is already defined — the engine's
own boot/loading sequence always takes longer than that.
The only catch: window.DavidnetSDK 's functions are plain JavaScript and return JS Promises, so a non-JS language needs its own engine-specific
bridge to call them and get the result back.
Full example project
Click Rush — a complete, working Godot 4.6 game that uses every single feature in these docs (highscores with multiple categories, saves, instant and progress-bar achievements, community levels, and realtime rooms/matchmaking/announcements), built with the bridge below. Grab either the source project (open it in Godot 4.6 and read through it) or the already-exported build (unzip and upload it directly to try it out first).
Godot 4.x: polling via JavaScriptBridge.eval()
JavaScriptBridge (only present in the HTML5 export, not the editor or other export targets) also offers JavaScriptBridge.create_callback(callable) , which wraps a GDScript Callable so JavaScript can call it asynchronously (e.g. from a Promise's .then() ) — the obvious choice for this bridge, and the one an earlier version of this page used. It turned out unreliable in practice : a call's resolve/reject callback could silently never fire even though the underlying HTTP request
genuinely succeeded — no error, the call just hung forever, seemingly independent of timing or how
many calls were in flight.
The bridge below avoids create_callback entirely and instead polls using just JavaScriptBridge.eval(code, true) 's return value — a plain synchronous call/return with nothing to "wire up" that can silently fail to be. Each call
gets a unique request id; the JS side stores its result in a small object keyed by that id as soon
as the Promise settles, and GDScript polls for it once per frame via another eval() call until it appears. Everything still passes as JSON strings both ways, so GDScript only ever
deals with normal Dictionary / Array values via its own built-in JSON.parse_string() / JSON.stringify() . Drop this in as an autoload (or anywhere that runs once on boot):
extends Node
# --- Davidnet SDK bridge for Godot HTML5 exports ---
# Deliberately NOT built on JavaScriptBridge.create_callback() for responses - it turned out
# unreliable in practice (a call's callback could silently never fire even though the request
# genuinely succeeded). Polling via JavaScriptBridge.eval()'s return value instead: eval() is a
# plain synchronous call/return, nothing gets "wired up" that can silently fail to be.
signal sdk_ready
var _web_capable := false
var _sdk_ready := false
var _next_request_id := 0
func _ready() -> void:
_web_capable = OS.has_feature("web")
if not _web_capable:
push_warning("DavidnetSDK bridge: not running in the HTML5 export - SDK calls will be no-ops.")
return
JavaScriptBridge.eval("""
window.__dnPending = {};
window.__dnStart = function(method, argsJson, requestId) {
var args = JSON.parse(argsJson);
var path = method.split(".");
var target = window.DavidnetSDK;
for (var i = 0; i < path.length - 1; i++) target = target[path[i]];
var fn = target[path[path.length - 1]];
Promise.resolve(fn.apply(target, args)).then(function(result) {
window.__dnPending[requestId] = {
success: true,
data: JSON.stringify(result === undefined ? null : result)
};
}).catch(function(err) {
window.__dnPending[requestId] = {
success: false,
data: String(err && err.message ? err.message : err)
};
});
};
window.__dnPoll = function(requestId) {
var entry = window.__dnPending[requestId];
if (!entry) return "";
delete window.__dnPending[requestId];
return JSON.stringify(entry);
};
""", true)
_sdk_ready = true
sdk_ready.emit()
# Calls any window.DavidnetSDK method - use a dot-path for nested ones, e.g. "realtime.send".
# on_success receives a parsed Dictionary/Array/primitive, on_error receives an error String.
# Polls once per frame for up to 11s - plenty responsive for a real ~30-40ms round trip, and safe
# to call from anywhere (including a node's own _ready(), or several calls at once - each has its
# own request id and polls independently).
func call_sdk(method: String, args: Array, on_success: Callable, on_error: Callable) -> void:
if not _web_capable:
push_warning("DavidnetSDK bridge: call_sdk(\"%s\") ignored - not running in the HTML5 export." % method)
on_error.call("DavidnetSDK is only available in the HTML5 export")
return
if not _sdk_ready:
await sdk_ready
_next_request_id += 1
var request_id := "r%d" % _next_request_id
JavaScriptBridge.eval(
"window.__dnStart(%s, %s, %s);" % [
JSON.stringify(method), JSON.stringify(JSON.stringify(args)), JSON.stringify(request_id)
],
true
)
var poll_js := "window.__dnPoll(%s)" % JSON.stringify(request_id)
var waited := 0.0
while waited < 11.0:
await get_tree().process_frame
waited += get_process_delta_time()
var raw = JavaScriptBridge.eval(poll_js, true)
if typeof(raw) == TYPE_STRING and raw != "":
var entry: Dictionary = JSON.parse_string(raw)
if entry.get("success", false):
on_success.call(JSON.parse_string(entry.get("data", "null")))
else:
on_error.call(entry.get("data", "Unknown error"))
return
push_warning("DavidnetSDK bridge: call_sdk(\"%s\") never got a response - giving up." % method)
on_error.call("No response from DavidnetSDK")Shown with Python syntax coloring above since GDScript isn't in the highlighter's language
list — the file itself is GDScript ( .gd ).
Example: submitting a score
func _on_game_over(final_score: int) -> void:
call_sdk("applyHighscore", [final_score], _on_highscore_submitted, _on_sdk_error)
func _on_highscore_submitted(result: Dictionary) -> void:
if result.get("isNewGlobalBest", false):
show_message("New world record!")
elif result.get("isNewPersonalBest", false):
show_message("New personal best!")
func _on_sdk_error(message: String) -> void:
print("DavidnetSDK call failed: ", message)Example: save data
func _ready() -> void:
call_sdk("getJsonBlob", [], _on_save_loaded, _on_sdk_error)
func _on_save_loaded(result: Dictionary) -> void:
var data = result.get("data")
if data == null:
start_new_game()
else:
apply_save_data(data)
func _on_checkpoint(state: Dictionary) -> void:
call_sdk("saveJsonBlob", [state], func(_r): pass, _on_sdk_error)Example: achievements
func _on_boss_defeated() -> void:
var achievement := {
"id": "first_boss_kill",
"name": "Giant Slayer",
"description": "Defeat the first boss",
"icon": "⚔️"
}
call_sdk("unlockAchievement", [achievement], _on_achievement_result, _on_sdk_error)
func _on_achievement_result(result: Dictionary) -> void:
if result.get("isNew", false):
show_message("Achievement unlocked: Giant Slayer!")Example: realtime calls
Nested methods (everything under DavidnetSDK.realtime ) use a dot-path, e.g. "realtime.joinQueue" , with the same call_sdk helper:
# Nested methods use a dot-path, e.g. "realtime.joinQueue". Listeners (onMessage,
# onMatched, ...) are not part of this request/response bridge - see the note below.
func _find_opponent() -> void:
call_sdk("realtime.joinQueue", ["ranked-1v1", 2], func(_r): pass, _on_sdk_error)
func _send_move(room: String, move: Dictionary) -> void:
call_sdk("realtime.send", [room, { "type": "move", "move": move }], func(_r): pass, _on_sdk_error)Realtime listeners (onMessage, onMatched, ...)
The call_sdk helper above is request/response shaped: one call in, one result back. Realtime listeners are different
— they fire on their own, any number of times, not in response to a specific call. The same polling
idea still works: push every event into a JS-side array as it happens, then drain that array once
per frame from _process() :
# Listeners (onMessage, onMatched, onState, ...) fire on their own, not in response to
# one call_sdk() request - the same polling idea works for these too: push every event
# into a JS-side array as it happens, drain that array once per frame from _process().
func _ready() -> void:
# ... after the __dnStart/__dnPoll setup above ...
JavaScriptBridge.eval("""
window.__dnRealtimeEvents = [];
function __dnPush(eventName) {
return function(data) { window.__dnRealtimeEvents.push({ event: eventName, data: data }); };
}
var rt = window.DavidnetSDK.realtime;
rt.onMessage(__dnPush("message"));
rt.onMatched(__dnPush("matched"));
rt.onPresence(__dnPush("presence"));
rt.onState(__dnPush("state"));
rt.onAnnouncement(__dnPush("announcement"));
window.__dnPollRealtimeEvents = function() {
if (window.__dnRealtimeEvents.length === 0) return "";
var events = window.__dnRealtimeEvents;
window.__dnRealtimeEvents = [];
return JSON.stringify(events);
};
""", true)
func _process(_delta: float) -> void:
var raw = JavaScriptBridge.eval("window.__dnPollRealtimeEvents()", true)
if typeof(raw) != TYPE_STRING or raw == "":
return
for payload in JSON.parse_string(raw):
match payload.event:
"matched":
_current_room = payload.data.get("room")
"message":
_apply_opponent_move(payload.data.get("data"))Export settings that work (and what doesn't)
Davidnet's game-file server does NOT send Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy headers, and won't by default — those headers would also block the documented "load anything from
a CDN" sandbox behavior for every game, not just yours. That has concrete implications for your
Web export settings ( Project > Export > your Web preset > Options ):
- Thread Support must be OFF. A threaded export requires
SharedArrayBuffer, which browsers only expose on a cross-origin-isolated page (the headers above). Upload a threaded build and players get a hard "Error: The following features required to run Godot projects on the Web are missing: Cross-Origin Isolation... SharedArrayBuffer..." screen instead of your game. Almost no GDScript-only game actually needs this — it only matters for heavy parallel computation you'd otherwise hand off to Godot's worker threads. - C# / .NET is not supported, full stop — not a Davidnet limitation, a Godot one: the official .NET/Mono export templates don't ship Web/HTML5
templates at all (checked directly against the official 4.6.3 release assets — the mono template
package has Android/iOS/Linux/macOS/Windows templates and zero Web ones). If your project uses
any
.csscripts, Web export isn't available in Godot itself, regardless of this platform. Use GDScript. - GDExtension (native addons) won't work either — same reasoning: nothing compiles anything for you on upload, and a native addon built for desktop can't run inside the browser's WASM sandbox regardless of what Davidnet does.
- The export filename must be
index.html. Whatever you type as the export path's filename becomes the name of every generated file (.html,.wasm,.pck,.js) — name itindex, not your project name, or your zip won't haveindex.htmlat its root and the upload will be rejected (see Troubleshooting ). - Everything else is fair game — texture compression settings, canvas resize policy, custom HTML shell, PWA options (though nothing serves a manifest for you inside the iframe, so install prompts won't do much there). None of it interacts with the sandbox.
Godot 3.x
The equivalent singleton is the older JavaScript (not JavaScriptBridge ) with a slightly different API — e.g. JSON.parse(text).result instead of JSON.parse_string(text) . The same JSON-bridge idea still works, but verify exact method names against the 3.x docs, or
upgrade to 4.x.
Unity (WebGL) and other engines
The same idea applies to any engine that exports to HTML5/WASM: find that engine's mechanism
for calling into page-level JavaScript and receiving a callback back (Unity WebGL uses .jslib plugin files with [DllImport("__Internal")] extern functions, for example), then apply the same pattern as above — one small JS helper that
resolves the Promise and hands the result back as a JSON string, so your engine-side code never
has to deal with JS objects or Promises directly.