Godot / other engines

← All Community Games topics

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

davidnet_sdk_bridge.gd
python
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

example_highscore.gd
python
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

example_save.gd
python
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

example_achievement.gd
python
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:

example_realtime.gd
python
# 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() :

example_realtime_listeners.gd
python
# 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 .cs scripts, 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 it index , not your project name, or your zip won't have index.html at 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.