diff --git a/.github/workflows/godot-build.yml b/.github/workflows/godot-build.yml
new file mode 100644
index 0000000..dd031f2
--- /dev/null
+++ b/.github/workflows/godot-build.yml
@@ -0,0 +1,54 @@
+name: Godot Web build
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ build:
+ permissions:
+ contents: read
+ name: Build Godot Web export
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
+
+ - name: Set up Godot
+ uses: chickensoft-games/setup-godot@f166999204a4f2722c6fe042fbaa3b3ea0d9c789
+ with:
+ version: 4.7.0
+ use-dotnet: false
+ include-templates: true
+ cache: true
+
+ - name: Export Godot Web build
+ run: make godot-export
+
+ - name: Upload GitHub Pages artifact
+ uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b
+ with:
+ path: dist
+
+ deploy:
+ name: Deploy to GitHub Pages
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-latest
+ concurrency:
+ group: github-pages
+ cancel-in-progress: false
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy GitHub Pages site
+ id: deployment
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e
diff --git a/Makefile b/Makefile
index 18fc732..81a9a42 100644
--- a/Makefile
+++ b/Makefile
@@ -18,4 +18,5 @@ godot-export:
mkdir -p dist; \
DIST_PATH="$$(pwd)/dist/index.html"; \
echo "Exporting Godot Web build with $$GODOT_BIN to $$DIST_PATH"; \
- "$$GODOT_BIN" --headless --path godot --export-release Web "$$DIST_PATH"
+ "$$GODOT_BIN" --headless --path godot --export-release Web "$$DIST_PATH"; \
+ cp godot/web/stellar_bridge.js "$$(dirname "$$DIST_PATH")/stellar_bridge.js"
diff --git a/README.md b/README.md
index 0f51e9a..ff4aab4 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Human-vs-bots
+[](https://github.com/Bitcoindefi/Human-vs-bots/actions/workflows/godot-build.yml)
+
Turn-based strategy game with a Web3-ready flow on Stellar.
## What this project is
@@ -95,6 +97,23 @@ The command detects `godot4` or `godot`, runs the `Web` export preset in headles
mode, and writes the generated entry point to `dist/index.html`. The generated
`dist/` directory is intentionally not tracked.
+Godot Web exports expose a defensive `WebBridge` autoload for asynchronous
+wallet, Stellar commit/reveal, and ZK proof operations. The browser function,
+callback, and payload contract is documented in
+[`godot/web/README.md`](godot/web/README.md).
+
+### Godot Web CI/CD
+
+The [Godot Web build workflow](.github/workflows/godot-build.yml) runs for every
+pull request and every push to `main`. Its `build` job installs and caches Godot
+4.7.0 plus the matching Web export templates, calls `make godot-export`, and
+uploads `dist/` as the GitHub Pages artifact.
+
+For pushes to `main`, the `deploy` job publishes that artifact to the
+`github-pages` environment with `actions/deploy-pages`. Pull requests build and
+upload the artifact for validation but never deploy it. The repository's Pages
+source must be set to **GitHub Actions** under **Settings → Pages**.
+
## Third-party references and licenses
- Unciv assets inspiration (visual assets used in demo pipeline)
diff --git a/godot/autoloads/WebBridge.gd b/godot/autoloads/WebBridge.gd
new file mode 100644
index 0000000..5826805
--- /dev/null
+++ b/godot/autoloads/WebBridge.gd
@@ -0,0 +1,168 @@
+extends Node
+
+signal wallet_connected(address: String)
+signal wallet_connection_failed(error: String)
+signal address_received(address: String)
+signal commit_submitted(result)
+signal reveal_submitted(result)
+signal proof_generated(result)
+signal proof_exported(result)
+signal bridge_error(action: String, error: String)
+
+const CALLBACK_NAMESPACE := "HumanVsBotsGodot"
+const BRIDGE_INTERFACE := "HumanVsBotsBridge"
+
+var _bridge = null
+var _callback_interface = null
+var _callback_references: Array = []
+
+
+func _ready() -> void:
+ if not OS.has_feature("web"):
+ return
+
+ JavaScriptBridge.eval(
+ "globalThis.%s = globalThis.%s || {};" % [CALLBACK_NAMESPACE, CALLBACK_NAMESPACE]
+ )
+ _callback_interface = JavaScriptBridge.get_interface(CALLBACK_NAMESPACE)
+ _bridge = JavaScriptBridge.get_interface(BRIDGE_INTERFACE)
+
+ if _callback_interface == null:
+ return
+
+ _register_callback("walletConnected", _on_wallet_connected)
+ _register_callback("walletConnectionFailed", _on_wallet_connection_failed)
+ _register_callback("addressReceived", _on_address_received)
+ _register_callback("commitSubmitted", _on_commit_submitted)
+ _register_callback("revealSubmitted", _on_reveal_submitted)
+ _register_callback("proofGenerated", _on_proof_generated)
+ _register_callback("proofExported", _on_proof_exported)
+ _register_callback("bridgeError", _on_bridge_error)
+
+
+func is_available() -> bool:
+ return OS.has_feature("web") and _bridge != null and _callback_interface != null
+
+
+func connect_wallet() -> void:
+ if _ensure_available("connect_wallet"):
+ _bridge.connectWallet()
+
+
+func get_address() -> void:
+ if _ensure_available("get_address"):
+ _bridge.getAddress()
+
+
+func commit_action(payload: Dictionary) -> void:
+ if _ensure_available("commit_action"):
+ _bridge.commitAction(JSON.stringify(payload))
+
+
+func reveal_action(payload: Dictionary) -> void:
+ if _ensure_available("reveal_action"):
+ _bridge.revealAction(JSON.stringify(payload))
+
+
+func generate_proof(payload: Dictionary) -> void:
+ if _ensure_available("generate_proof"):
+ _bridge.generateProof(JSON.stringify(payload))
+
+
+func export_proof(payload: Dictionary) -> void:
+ if _ensure_available("export_proof"):
+ _bridge.exportProof(JSON.stringify(payload))
+
+
+func _register_callback(callback_name: String, callable: Callable) -> void:
+ var callback = JavaScriptBridge.create_callback(callable)
+ _callback_references.append(callback)
+ _callback_interface.set(callback_name, callback)
+
+
+func _ensure_available(action: String) -> bool:
+ if not OS.has_feature("web"):
+ _report_error(action, "WebBridge is only available in Godot Web exports.")
+ return false
+ if _callback_interface == null:
+ _report_error(action, "Could not register the browser callback interface.")
+ return false
+ if _bridge == null:
+ _report_error(
+ action,
+ "window.%s is unavailable; load stellar_bridge.js before Godot starts." % BRIDGE_INTERFACE
+ )
+ return false
+ return true
+
+
+func _on_wallet_connected(arguments: Array) -> void:
+ var address := _read_string_argument("connect_wallet", arguments)
+ if not address.is_empty():
+ wallet_connected.emit(address)
+
+
+func _on_wallet_connection_failed(arguments: Array) -> void:
+ var error := _read_string_argument("connect_wallet", arguments)
+ if not error.is_empty():
+ wallet_connection_failed.emit(error)
+
+
+func _on_address_received(arguments: Array) -> void:
+ var address := _read_string_argument("get_address", arguments)
+ if not address.is_empty():
+ address_received.emit(address)
+
+
+func _on_commit_submitted(arguments: Array) -> void:
+ _emit_json_result("commit_action", commit_submitted, arguments)
+
+
+func _on_reveal_submitted(arguments: Array) -> void:
+ _emit_json_result("reveal_action", reveal_submitted, arguments)
+
+
+func _on_proof_generated(arguments: Array) -> void:
+ _emit_json_result("generate_proof", proof_generated, arguments)
+
+
+func _on_proof_exported(arguments: Array) -> void:
+ _emit_json_result("export_proof", proof_exported, arguments)
+
+
+func _on_bridge_error(arguments: Array) -> void:
+ if arguments.size() < 2:
+ _report_error("unknown", "Browser bridge returned an incomplete error callback.")
+ return
+ bridge_error.emit(str(arguments[0]), str(arguments[1]))
+
+
+func _read_string_argument(action: String, arguments: Array) -> String:
+ if arguments.is_empty():
+ _report_error(action, "Browser bridge callback did not include a value.")
+ return ""
+ var value := str(arguments[0])
+ if value.is_empty():
+ _report_error(action, "Browser bridge callback returned an empty value.")
+ return value
+
+
+func _emit_json_result(action: String, target_signal: Signal, arguments: Array) -> void:
+ if arguments.is_empty() or typeof(arguments[0]) != TYPE_STRING:
+ _report_error(action, "Browser bridge callback must include a JSON string.")
+ return
+
+ var json := JSON.new()
+ var parse_error := json.parse(arguments[0])
+ if parse_error != OK:
+ _report_error(
+ action,
+ "Browser bridge returned invalid JSON: %s" % json.get_error_message()
+ )
+ return
+ target_signal.emit(json.data)
+
+
+func _report_error(action: String, error: String) -> void:
+ push_warning("%s: %s" % [action, error])
+ bridge_error.emit(action, error)
diff --git a/godot/export_presets.cfg b/godot/export_presets.cfg
index 07a6945..ccad80a 100644
--- a/godot/export_presets.cfg
+++ b/godot/export_presets.cfg
@@ -21,7 +21,7 @@ vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=true
html/export_icon=true
html/custom_html_shell=""
-html/head_include=""
+html/head_include=""
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false
diff --git a/godot/project.godot b/godot/project.godot
index e7777eb..7a31998 100644
--- a/godot/project.godot
+++ b/godot/project.godot
@@ -12,6 +12,10 @@ config_version=5
config/name="HumanVsBots"
run/main_scene="res://scenes/Main.tscn"
+[autoload]
+
+WebBridge="*res://autoloads/WebBridge.gd"
+
[display]
window/size/viewport_width=1280
diff --git a/godot/web/README.md b/godot/web/README.md
new file mode 100644
index 0000000..46b4638
--- /dev/null
+++ b/godot/web/README.md
@@ -0,0 +1,89 @@
+# Godot WebBridge contract
+
+`WebBridge` is a Godot autoload that exchanges asynchronous Stellar/ZK requests
+with browser JavaScript. It is active only in a Godot Web export. Native/editor
+calls do not touch `JavaScriptBridge`; they emit `bridge_error` instead.
+
+## Loading the browser adapter
+
+The Web export preset adds this tag to the generated page:
+
+```html
+
+```
+
+`make godot-export` copies `godot/web/stellar_bridge.js` next to the generated
+`dist/index.html`. If exporting from the Godot editor instead, copy that file
+next to the exported HTML manually. The existing repository-root `index.html`
+and demo pages are not part of this integration.
+
+Before the game makes a request, configure the adapter with the real wallet,
+Stellar, and proof implementations:
+
+```js
+window.HumanVsBotsBridge.configure({
+ connectWallet: async () => ({ address: "G..." }),
+ getAddress: async () => "G...",
+ commitAction: async (payload) => ({ txHash: "..." }),
+ revealAction: async (payload) => ({ txHash: "..." }),
+ generateProof: async (payload) => ({ proof: "...", publicInputs: [] }),
+ exportProof: async (payload) => ({ filename: "proof.json" }),
+});
+```
+
+Handlers may return a value or a Promise. `commitAction`, `revealAction`,
+`generateProof`, and `exportProof` receive the parsed JSON object sent by Godot.
+This adapter deliberately does not select a wallet SDK, submit transactions, or
+generate proofs itself.
+
+## JavaScript functions called by Godot
+
+Godot expects these functions on `window.HumanVsBotsBridge`:
+
+| Function | Input | Successful handler result |
+| --- | --- | --- |
+| `connectWallet()` | none | address string or `{ address: "G..." }` |
+| `getAddress()` | none | address string or `{ address: "G..." }` |
+| `commitAction(payloadJson)` | JSON string | any JSON-serializable value |
+| `revealAction(payloadJson)` | JSON string | any JSON-serializable value |
+| `generateProof(payloadJson)` | JSON string | any JSON-serializable value |
+| `exportProof(payloadJson)` | JSON string | any JSON-serializable value |
+
+## Callbacks into Godot
+
+At startup, the autoload creates `window.HumanVsBotsGodot` and registers:
+
+| Callback | Arguments | Godot signal |
+| --- | --- | --- |
+| `walletConnected` | `address` string | `wallet_connected(address)` |
+| `walletConnectionFailed` | error string | `wallet_connection_failed(error)` |
+| `addressReceived` | `address` string | `address_received(address)` |
+| `commitSubmitted` | JSON result string | `commit_submitted(result)` |
+| `revealSubmitted` | JSON result string | `reveal_submitted(result)` |
+| `proofGenerated` | JSON result string | `proof_generated(result)` |
+| `proofExported` | JSON result string | `proof_exported(result)` |
+| `bridgeError` | action string, error string | `bridge_error(action, error)` |
+
+The supplied adapter invokes these callbacks after each handler settles.
+Integrations that replace the adapter must use the same callback names and must
+JSON-stringify action/proof results. Expected action names for `bridgeError` are
+`connect_wallet`, `get_address`, `commit_action`, `reveal_action`,
+`generate_proof`, and `export_proof`.
+
+## Godot usage
+
+```gdscript
+func _ready() -> void:
+ WebBridge.wallet_connected.connect(_on_wallet_connected)
+ WebBridge.commit_submitted.connect(_on_commit_submitted)
+ WebBridge.bridge_error.connect(_on_bridge_error)
+
+ WebBridge.connect_wallet()
+ WebBridge.commit_action({
+ "turn": 1,
+ "commitment": "hex-or-base64-commitment",
+ })
+```
+
+Requests return through signals; wrapper methods do not block or return a
+transaction/proof result.
diff --git a/godot/web/stellar_bridge.js b/godot/web/stellar_bridge.js
new file mode 100644
index 0000000..8f7b218
--- /dev/null
+++ b/godot/web/stellar_bridge.js
@@ -0,0 +1,122 @@
+/* global HumanVsBotsGodot */
+
+/**
+ * Browser adapter for the Godot WebBridge autoload.
+ *
+ * The host application configures Stellar/ZK implementations with
+ * `window.HumanVsBotsBridge.configure(handlers)`. Every handler may return a
+ * value or a Promise. Godot sends action payloads as JSON strings and this
+ * adapter sends result payloads back as JSON strings.
+ */
+(function installHumanVsBotsBridge(global) {
+ "use strict";
+
+ let handlers = global.HumanVsBotsIntegrations || {};
+
+ function configure(nextHandlers) {
+ if (!nextHandlers || typeof nextHandlers !== "object") {
+ throw new TypeError("HumanVsBotsBridge.configure expects an object.");
+ }
+ handlers = nextHandlers;
+ }
+
+ function callback(name) {
+ const godotCallbacks = global.HumanVsBotsGodot;
+ return godotCallbacks && typeof godotCallbacks[name] === "function"
+ ? godotCallbacks[name]
+ : null;
+ }
+
+ function notify(name, ...args) {
+ const target = callback(name);
+ if (target) {
+ target(...args);
+ return;
+ }
+ console.error(`Godot bridge callback "${name}" is not registered.`);
+ }
+
+ function errorMessage(error) {
+ if (error && typeof error.message === "string") {
+ return error.message;
+ }
+ return String(error);
+ }
+
+ function parsePayload(payload) {
+ return typeof payload === "string" ? JSON.parse(payload) : payload;
+ }
+
+ function serializeResult(result) {
+ return JSON.stringify(result === undefined ? null : result);
+ }
+
+ function requireHandler(name) {
+ if (typeof handlers[name] !== "function") {
+ throw new Error(`Bridge handler "${name}" is not configured.`);
+ }
+ return handlers[name];
+ }
+
+ async function run(action, handlerName, successCallback, payload) {
+ try {
+ const handler = requireHandler(handlerName);
+ const result = arguments.length >= 4
+ ? await handler(parsePayload(payload))
+ : await handler();
+ notify(successCallback, serializeResult(result));
+ return result;
+ } catch (error) {
+ notify("bridgeError", action, errorMessage(error));
+ return null;
+ }
+ }
+
+ async function connectWallet() {
+ try {
+ const result = await requireHandler("connectWallet")();
+ const address = typeof result === "string" ? result : result && result.address;
+ if (typeof address !== "string" || address.length === 0) {
+ throw new Error("connectWallet must return an address string or { address }.");
+ }
+ notify("walletConnected", address);
+ return result;
+ } catch (error) {
+ const message = errorMessage(error);
+ notify("walletConnectionFailed", message);
+ notify("bridgeError", "connect_wallet", message);
+ return null;
+ }
+ }
+
+ async function getAddress() {
+ try {
+ const result = await requireHandler("getAddress")();
+ const address = typeof result === "string" ? result : result && result.address;
+ if (typeof address !== "string" || address.length === 0) {
+ throw new Error("getAddress must return an address string or { address }.");
+ }
+ notify("addressReceived", address);
+ return result;
+ } catch (error) {
+ notify("bridgeError", "get_address", errorMessage(error));
+ return null;
+ }
+ }
+
+ const bridge = {
+ configure,
+ connectWallet,
+ getAddress,
+ commitAction: (payload) =>
+ run("commit_action", "commitAction", "commitSubmitted", payload),
+ revealAction: (payload) =>
+ run("reveal_action", "revealAction", "revealSubmitted", payload),
+ generateProof: (payload) =>
+ run("generate_proof", "generateProof", "proofGenerated", payload),
+ exportProof: (payload) =>
+ run("export_proof", "exportProof", "proofExported", payload),
+ };
+
+ global.HumanVsBotsBridge = bridge;
+})(globalThis);
diff --git a/godot/web/stellar_bridge.test.js b/godot/web/stellar_bridge.test.js
new file mode 100644
index 0000000..20c58b7
--- /dev/null
+++ b/godot/web/stellar_bridge.test.js
@@ -0,0 +1,77 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+const vm = require("node:vm");
+
+function loadBridge() {
+ const callbacks = [];
+ const context = {
+ console,
+ HumanVsBotsGodot: {
+ walletConnected: (address) => callbacks.push(["walletConnected", address]),
+ walletConnectionFailed: (error) => callbacks.push(["walletConnectionFailed", error]),
+ addressReceived: (address) => callbacks.push(["addressReceived", address]),
+ commitSubmitted: (result) => callbacks.push(["commitSubmitted", result]),
+ revealSubmitted: (result) => callbacks.push(["revealSubmitted", result]),
+ proofGenerated: (result) => callbacks.push(["proofGenerated", result]),
+ proofExported: (result) => callbacks.push(["proofExported", result]),
+ bridgeError: (action, error) => callbacks.push(["bridgeError", action, error]),
+ },
+ };
+ vm.createContext(context);
+ const source = fs.readFileSync(path.join(__dirname, "stellar_bridge.js"), "utf8");
+ vm.runInContext(source, context);
+ return { bridge: context.HumanVsBotsBridge, callbacks };
+}
+
+test("routes wallet and address results to Godot callbacks", async () => {
+ const { bridge, callbacks } = loadBridge();
+ bridge.configure({
+ connectWallet: async () => ({ address: "GCONNECTED" }),
+ getAddress: async () => "GADDRESS",
+ });
+
+ await bridge.connectWallet();
+ await bridge.getAddress();
+
+ assert.deepEqual(callbacks, [
+ ["walletConnected", "GCONNECTED"],
+ ["addressReceived", "GADDRESS"],
+ ]);
+});
+
+test("parses action payloads and serializes async results", async () => {
+ const { bridge, callbacks } = loadBridge();
+ bridge.configure({
+ commitAction: async (payload) => ({ txHash: `commit-${payload.turn}` }),
+ revealAction: async (payload) => ({ txHash: `reveal-${payload.turn}` }),
+ generateProof: async (payload) => ({ proof: payload.state }),
+ exportProof: async (payload) => ({ filename: `${payload.turn}.json` }),
+ });
+
+ await bridge.commitAction('{"turn":7}');
+ await bridge.revealAction('{"turn":7}');
+ await bridge.generateProof('{"state":"state-hash"}');
+ await bridge.exportProof('{"turn":7}');
+
+ assert.deepEqual(callbacks, [
+ ["commitSubmitted", '{"txHash":"commit-7"}'],
+ ["revealSubmitted", '{"txHash":"reveal-7"}'],
+ ["proofGenerated", '{"proof":"state-hash"}'],
+ ["proofExported", '{"filename":"7.json"}'],
+ ]);
+});
+
+test("reports missing handlers without rejecting into the browser", async () => {
+ const { bridge, callbacks } = loadBridge();
+
+ await bridge.connectWallet();
+ await bridge.commitAction("{}");
+
+ assert.deepEqual(callbacks, [
+ ["walletConnectionFailed", 'Bridge handler "connectWallet" is not configured.'],
+ ["bridgeError", "connect_wallet", 'Bridge handler "connectWallet" is not configured.'],
+ ["bridgeError", "commit_action", 'Bridge handler "commitAction" is not configured.'],
+ ]);
+});