#Godot
Godot's web export drops straight into the arcade, and JavaScriptBridge gives
GDScript a way to call Arcade. Read shipping an engine
export first for the rules every engine shares.
#Export settings
Project → Export → add a Web preset. Set the export path to
games/<your-slug>/public/index.html — the filename matters, the arcade needs
index.html at the top of outputDir.
Then, in the preset:
| Setting | Value | Why |
|---|---|---|
| Export Type | Regular | The threaded export needs SharedArrayBuffer, which the arcade can't grant |
| Extensions Support | off, unless you need it | It forces the threaded path |
| Variant / Thread Support | single-threaded | Same reason |
Then game.json:
"build": { "command": null, "outputDir": "public" }
Commit the whole export: index.html, the .js, .wasm, .pck, and the audio
worklet file. Missing the .pck is the classic mistake — the game boots to a
blank canvas and the console shows a failed fetch.
#Calling the arcade from GDScript
JavaScriptBridge only exists in a web build, so guard every use of it. A small
autoload wrapper keeps that in one place rather than scattered through your game:
# arcade.gd — register as an autoload named "ArcadeSDK"
extends Node
var _web := false
func _ready() -> void:
_web = OS.has_feature("web") and JavaScriptBridge.get_interface("Arcade") != null
if _web:
JavaScriptBridge.eval("Arcade.init()", true)
func submit_score(score: int) -> void:
if not _web: return
# The catch matters: the SDK rejects when signed out or offline, and an
# unhandled rejection is noise in the player's console.
JavaScriptBridge.eval("Arcade.leaderboard.submit(%d).catch(function(){})" % score, true)
func unlock(id: String) -> void:
if not _web: return
JavaScriptBridge.eval("Arcade.achievements.unlock(%s).catch(function(){})" % JSON.stringify(id), true)
func set_stats(coins: int, lives: int) -> void:
if not _web: return
JavaScriptBridge.eval("""
Arcade.nav.open({ stats: [
{ icon: "🪙", value: %d, label: "Coins" },
{ icon: "❤", value: %d, label: "Lives" }
]})""" % [coins, lives], true)
func guard(on: bool, reason: String = "") -> void:
if not _web: return
var arg := JSON.stringify(reason) if on and reason != "" else str(on).to_lower()
JavaScriptBridge.eval("Arcade.nav.guard(%s)" % arg, true)
Then from anywhere in your game:
ArcadeSDK.submit_score(final_score)
ArcadeSDK.unlock("first-win")
ArcadeSDK.guard(true, "Leaving now abandons this run.")
Two details that will save you an afternoon:
- Pass
trueas the second argument toeval. That runs the code in the global context, which is whereArcadelives. Without it you're evaluating inside Godot's own module scope andArcadeis undefined. - Build strings with
JSON.stringify, not%s. A card namedBlaze "Nine"will otherwise close your JS string and throw.
#Reading a value back
eval returns the value when it's a primitive, so a signed-in check is direct:
func is_signed_in() -> bool:
if not _web: return false
return bool(JavaScriptBridge.eval("!!document.querySelector('.aa-trigger')", true))
For anything asynchronous, hand the SDK a callback instead. Keep a reference to it: Godot frees the callback when it goes out of scope, and the JS side is then calling into nothing.
var _on_scores: JavaScriptObject
func fetch_top() -> void:
if not _web: return
_on_scores = JavaScriptBridge.create_callback(_scores_arrived)
var window = JavaScriptBridge.get_interface("window")
window.arcadeScoresCb = _on_scores
JavaScriptBridge.eval("Arcade.leaderboard.top(10).then(function(s){ window.arcadeScoresCb(JSON.stringify(s)); }).catch(function(){})", true)
func _scores_arrived(args: Array) -> void:
var scores: Array = JSON.parse_string(args[0])
print(scores)
#Leave room for the top bar
The simplest answer for a full-viewport export is to collapse the bar. Add to
game.json:
"navBar": "collapsed"
It then sits off-screen behind a small handle the player can pull down, and
--arcade-nav-h becomes the handle's height rather than the bar's.
If you'd rather keep it expanded: the arcade reserves the bar's strip as
padding-top on <body>, which handles anything in normal flow. Godot isn't —
it sizes its canvas to the viewport, so the canvas ignores that padding and its
top runs under the bar. The fix is a custom HTML shell (Export → Custom HTML
Shell) that subtracts the strip:
canvas { height: calc(100vh - var(--arcade-nav-h, 0px)); }
Keep the 0px fallback so the canvas still fills the window when the game runs
outside the arcade. Or design the top strip as somewhere nothing important goes,
which most games do anyway.
#Common problems
| What you see | Cause |
|---|---|
| Blank canvas, failed fetch in the console | The .pck wasn't committed, or it's next to the wrong filename |
| Boots locally, blank when deployed | A threaded export, or pre-compressed files. See web export |
Arcade is not defined |
Missing true on eval, so it ran outside the global context |
Assets 404 under /games/<slug>/ |
Absolute paths in a hand-edited shell |
The arcade side of this is verified: the injected bar,
window.Arcade, and.wasm/.pckserving are all exercised against an export-shaped game. The GDScript above follows Godot's documentedJavaScriptBridgeAPI but isn't run by this repo's CI, since CI has no engine installed. Check it on your first export and please correct this page if anything differs.
See also: shipping an engine export · the arcade nav bar · SDK reference