#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 true as the second argument to eval. That runs the code in the global context, which is where Arcade lives. Without it you're evaluating inside Godot's own module scope and Arcade is undefined.
  • Build strings with JSON.stringify, not %s. A card named Blaze "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/.pck serving are all exercised against an export-shaped game. The GDScript above follows Godot's documented JavaScriptBridge API 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

View docs/engines/godot.md on GitHub ↗