diff --git a/godot/bomber/project.godot b/godot/bomber/project.godot
index a29235b..91bfe95 100644
--- a/godot/bomber/project.godot
+++ b/godot/bomber/project.godot
@@ -24,6 +24,7 @@ gamestate="*res://scripts/gamestate.gd"
RivetHelper="*res://addons/rivet/rivet_helper.gd"
Rivet="*res://addons/rivet/rivet_global.gd"
RivetGlobal="*res://addons/rivet/rivet_global.gd"
+RivetClient="*res://addons/rivet/rivet_client.gd"
[display]
diff --git a/godot/circles/.gitattributes b/godot/circles/.gitattributes
new file mode 100644
index 0000000..8ad74f7
--- /dev/null
+++ b/godot/circles/.gitattributes
@@ -0,0 +1,2 @@
+# Normalize EOL for all files that Git considers text files.
+* text=auto eol=lf
diff --git a/godot/circles/.gitignore b/godot/circles/.gitignore
new file mode 100644
index 0000000..4709183
--- /dev/null
+++ b/godot/circles/.gitignore
@@ -0,0 +1,2 @@
+# Godot 4+ specific ignores
+.godot/
diff --git a/godot/circles/Dockerfile b/godot/circles/Dockerfile
new file mode 100644
index 0000000..6d317e6
--- /dev/null
+++ b/godot/circles/Dockerfile
@@ -0,0 +1,19 @@
+FROM ghcr.io/rivet-gg/godot-docker/godot:4.2.1 AS builder
+WORKDIR /app
+COPY . .
+RUN mkdir -p build/linux \
+ && godot -v --export-release "Linux/X11" ./build/linux/game.x86_64 --headless
+
+FROM ubuntu:22.04
+RUN apt update -y \
+ && apt install -y expect-dev \
+ && rm -rf /var/lib/apt/lists/* \
+ && useradd -ms /bin/bash rivet
+
+COPY --from=builder /app/build/linux/ /app
+
+# Change to user rivet
+USER rivet
+
+# Unbuffer output so the logs get flushed
+CMD ["sh", "-c", "unbuffer /app/game.x86_64 --verbose --headless -- --server | cat"]
diff --git a/godot/circles/addons/rivet/api/rivet_api.gd b/godot/circles/addons/rivet/api/rivet_api.gd
new file mode 100644
index 0000000..22488a3
--- /dev/null
+++ b/godot/circles/addons/rivet/api/rivet_api.gd
@@ -0,0 +1,132 @@
+class_name RivetApi
+const RivetRequest = preload("rivet_request.gd")
+
+static var CONFIGURATION_CACHE
+
+static func _get_configuration():
+ if CONFIGURATION_CACHE:
+ return CONFIGURATION_CACHE
+
+ if FileAccess.file_exists(RivetPluginBridge.RIVET_CONFIGURATION_FILE_PATH):
+ var config_file = ResourceLoader.load(RivetPluginBridge.RIVET_CONFIGURATION_FILE_PATH)
+ if config_file and 'new' in config_file:
+ CONFIGURATION_CACHE = config_file.new()
+ return CONFIGURATION_CACHE
+
+ if FileAccess.file_exists(RivetPluginBridge.RIVET_DEPLOYED_CONFIGURATION_FILE_PATH):
+ var deployed_config_file = ResourceLoader.load(RivetPluginBridge.RIVET_DEPLOYED_CONFIGURATION_FILE_PATH)
+ if deployed_config_file and 'new' in deployed_config_file:
+ CONFIGURATION_CACHE = deployed_config_file.new()
+ return CONFIGURATION_CACHE
+
+ push_warning("Rivet configuration file not found")
+ CONFIGURATION_CACHE = null
+ return CONFIGURATION_CACHE
+
+static func _get_api_url():
+ # Use plugin config if available
+ var plugin = RivetPluginBridge.get_plugin()
+ if plugin:
+ return plugin.api_endpoint
+
+ # Override shipped configuration endpoint
+ var url_env = OS.get_environment("RIVET_API_ENDPOINT")
+ if url_env:
+ return url_env
+
+ # Use configuration shipped with game
+ var config = _get_configuration()
+ if config:
+ return config.api_endpoint
+
+ # Fallback
+ return "https://api.rivet.gg"
+
+## Get authorization token used from within only the plugin for cloud-specific
+## actions.
+static func _get_cloud_token():
+ # Use plugin config if available
+ var plugin = RivetPluginBridge.get_plugin()
+ if plugin:
+ return plugin.cloud_token
+
+ OS.crash("Rivet cloud token not found, this should only be called within the plugin")
+
+## Get authorization token used for making requests from within the game.
+##
+## The priority of tokens is:
+##
+## - If in editor, use the plugin token
+## - If provided by environment, then use that (allows for testing)
+## - Assume config is provided by the game client
+static func _get_runtime_token():
+ # Use plugin config if available
+ var plugin = RivetPluginBridge.get_plugin()
+ if plugin:
+ return plugin.namespace_token
+
+ # Use configuration shipped with game
+ var token_env = OS.get_environment("RIVET_TOKEN")
+ if token_env:
+ return token_env
+
+ # Use configuration shipped with game
+ var config = _get_configuration()
+ if config:
+ return config.namespace_token
+
+ OS.crash("Rivet token not found, validate a config is shipped with the game in the .rivet folder")
+
+## Builds the headers for a request, including the authorization token
+static func _build_headers(service: String) -> PackedStringArray:
+ var token = _get_cloud_token() if service == "cloud" else _get_runtime_token()
+ return [
+ "Authorization: Bearer " + token,
+ ]
+
+## Builds a URL to Rivet cloud services
+static func _build_url(path: String, service: String) -> String:
+ var path_segments := path.split("/", false)
+ path_segments.remove_at(0)
+ return _get_api_url() + "/%s/%s" % [service, "/".join(path_segments)]
+
+## Gets service name from a path (e.g. /users/123 -> users)
+static func _get_service_from_path(path: String) -> String:
+ var path_segments := path.split("/", false)
+ return path_segments[0]
+
+## Creates a POST request to Rivet cloud services
+## @experimental
+static func POST(owner: Node, path: String, body: Dictionary) -> RivetRequest:
+ var service := _get_service_from_path(path)
+ var url := _build_url(path, service)
+ var body_json := JSON.stringify(body)
+
+ return RivetRequest.new(owner, HTTPClient.METHOD_POST, url, {
+ "headers": _build_headers(service),
+ "body": body_json
+ })
+
+## Creates a GET request to Rivet cloud services
+## @experimental
+static func GET(owner: Node, path: String, body: Dictionary) -> RivetRequest:
+ var service := _get_service_from_path(path)
+ var url := _build_url(path, service)
+ var body_json := JSON.stringify(body)
+
+ return RivetRequest.new(owner, HTTPClient.METHOD_GET, url, {
+ "headers": _build_headers(service),
+ "body": body_json
+ })
+
+## Creates a PUT request to Rivet cloud services
+## @experimental
+static func PUT(owner: Node, path: String, body: Dictionary) -> RivetRequest:
+ var service := _get_service_from_path(path)
+ var url := _build_url(path, service)
+ var body_json := JSON.stringify(body)
+
+ return RivetRequest.new(owner, HTTPClient.METHOD_PUT, url, {
+ "headers": _build_headers(service),
+ "body": body_json
+ })
diff --git a/godot/circles/addons/rivet/api/rivet_packages.gd b/godot/circles/addons/rivet/api/rivet_packages.gd
new file mode 100644
index 0000000..5f0bcd7
--- /dev/null
+++ b/godot/circles/addons/rivet/api/rivet_packages.gd
@@ -0,0 +1,97 @@
+const _RivetResponse = preload("rivet_response.gd")
+
+## Lobbies
+## @experimental
+class Lobbies:
+ ## Finds a lobby based on the given criteria. If a lobby is not found and
+ ## prevent_auto_create_lobby is true, a new lobby will be created.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/find}[/url]
+ func find(body: Dictionary = {}):
+ return await Rivet.POST("matchmaker/lobbies/find", body).wait_completed()
+
+ ## Joins a specific lobby. This request will use the direct player count
+ ## configured for the lobby group.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/join}[/url]
+ func join(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.POST("matchmaker/lobbies/join", body).wait_completed()
+
+ ## Marks the current lobby as ready to accept connections. Players will not
+ ## be able to connect to this lobby until the lobby is flagged as ready.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/ready}[/url]
+ func ready(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.POST("matchmaker/lobbies/ready", body).wait_completed()
+
+ ## If is_closed is true, the matchmaker will no longer route players to the
+ ## lobby. Players can still join using the /join endpoint (this can be disabled
+ ## by the developer by rejecting all new connections after setting the lobby
+ ## to closed). Does not shutdown the lobby.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/set-closed}[/url]
+ func setClosed(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.PUT("matchmaker/lobbies/set_closed", body).wait_completed()
+
+ ## Creates a custom lobby.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/create}[/url]
+ func create(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.POST("matchmaker/lobbies/create", body).wait_completed()
+
+ ## Lists all open lobbies.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/list}[/url]
+ func list(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.GET("matchmaker/lobbies/list", body).wait_completed()
+
+ ##
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/set-state}[/url]
+ func setState(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.PUT("matchmaker/lobbies/state", body).wait_completed()
+
+ ##
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/lobbies/get-state}[/url]
+ func getState(lobby_id, body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.GET("matchmaker/lobbies/{lobby_id}/state".format({"lobby_id": lobby_id}), body).wait_completed()
+
+## Players
+## @experimental
+class Players:
+ ## Validates the player token is valid and has not already been consumed then
+ ## marks the player as connected.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/players/connected}[/url]
+ func connected(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.POST("matchmaker/players/connected", body).wait_completed()
+
+ ## Marks a player as disconnected. # Ghost Players.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/players/disconnected}[/url]
+ func disconnected(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.POST("matchmaker/players/disconnected", body).wait_completed()
+
+ ## Gives matchmaker statistics about the players in game.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/players/statistics}[/url]
+ func getStatistics(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.GET("matchmaker/players/statistics", body).wait_completed()
+
+class Regions:
+ ## Returns a list of regions available to this namespace.
+ ## Regions are sorted by most optimal to least optimal.
+ ## The player's IP address is used to calculate the regions' optimality.
+ ##
+ ## [url]{https://rivet.gg/docs/matchmaker/api/regions/list}[/url]
+ func list(body: Dictionary = {}) -> _RivetResponse:
+ return await Rivet.GET("matchmaker/regions", body).wait_completed()
+
+## Matchmaker
+## @experimental
+## @tutorial: https://rivet.gg/docs/matchmaker
+class Matchmaker:
+ static var lobbies: Lobbies = Lobbies.new()
+ static var players: Players = Players.new()
+ static var regions: Regions = Regions.new()
diff --git a/godot/circles/addons/rivet/api/rivet_request.gd b/godot/circles/addons/rivet/api/rivet_request.gd
new file mode 100644
index 0000000..95bb483
--- /dev/null
+++ b/godot/circles/addons/rivet/api/rivet_request.gd
@@ -0,0 +1,57 @@
+extends RefCounted
+## A wrapper around HTTPRequest that emits a signal when the request is completed.
+## This is a workaround for the fact that `HTTPRequest.request()` is blocking.
+## To run a request, create a new RivetRequest, connect to the completed signal,
+## and call `request().wait_completed()` to wait for the request to complete.
+
+
+const _RivetResponse := preload("rivet_response.gd")
+const _RivetRequest := preload("rivet_request.gd")
+
+var response: _RivetResponse = null
+var _opts: Dictionary
+var _http_request: HTTPRequest
+
+var _success_callback: Callable
+var _failure_callback: Callable
+
+signal completed(response: _RivetResponse)
+signal succeeded(response: _RivetResponse)
+signal failed(response: _RivetResponse)
+
+func _init(owner: Node, method: HTTPClient.Method, url: String, opts: Variant = null):
+ self._http_request = HTTPRequest.new()
+ self._http_request.request_completed.connect(_on_request_completed)
+ self._opts = {
+ "method": method,
+ "url": url,
+ "body": opts.body,
+ "headers": opts.headers,
+ }
+ owner.add_child(self._http_request)
+ self._http_request.request(_opts.url, _opts.headers, _opts.method, _opts.body)
+
+func set_success_callback(callback: Callable) -> _RivetRequest:
+ self._success_callback = callback
+ return self
+
+func set_failure_callback(callback: Callable) -> _RivetRequest:
+ self._failure_callback = callback
+ return self
+
+func _on_request_completed(result, response_code, headers, body):
+ self.response = _RivetResponse.new(result, response_code, headers, body)
+ if result == OK:
+ succeeded.emit(response)
+ if self._success_callback:
+ self._success_callback.call(response)
+ else:
+ failed.emit(response)
+ if self._failure_callback:
+ self._failure_callback.call(response)
+ completed.emit(response)
+
+## Waits for the request to complete and returns the response in non-blocking way
+func wait_completed() -> _RivetResponse:
+ await completed
+ return response
diff --git a/godot/circles/addons/rivet/api/rivet_response.gd b/godot/circles/addons/rivet/api/rivet_response.gd
new file mode 100644
index 0000000..be954f9
--- /dev/null
+++ b/godot/circles/addons/rivet/api/rivet_response.gd
@@ -0,0 +1,27 @@
+extends RefCounted
+## A response from the server. Contains the result, response code, headers, and body.
+## The body is a dictionary of the JSON response.
+##
+## @experimental
+
+## The result of the request. 0 is success, 1 is failure.
+var result: HTTPClient.Status
+
+## The response code from the server.
+var response_code: HTTPClient.ResponseCode
+
+## The headers from the server.
+var headers: PackedStringArray
+
+## The body of the response, as a JSON dictionary, could be a null.
+var body: Variant
+
+func _init(result: int, response_code: int, headers: PackedStringArray, response_body: PackedByteArray) -> void:
+ self.result = result
+ self.response_code = response_code
+ self.headers = headers
+
+ var json = JSON.new()
+ json.parse(response_body.get_string_from_utf8())
+ body = json.get_data()
+
diff --git a/godot/circles/addons/rivet/devtools/dock/deploy_tab.gd b/godot/circles/addons/rivet/devtools/dock/deploy_tab.gd
new file mode 100644
index 0000000..c2654ac
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/deploy_tab.gd
@@ -0,0 +1,33 @@
+@tool extends MarginContainer
+
+@onready var namespace_selector: OptionButton = %DeployNamespaceSelector
+@onready var manage_versions_button: Button = %ManageVersionButton
+@onready var build_deploy_button: Button = %BuildDeployButton
+
+func _ready() -> void:
+ manage_versions_button.pressed.connect(_on_manage_versions_button_pressed)
+ build_deploy_button.pressed.connect(_on_build_deploy_button_pressed)
+
+func _on_manage_versions_button_pressed() -> void:
+ _all_actions_set_disabled(true)
+
+ var result = await RivetPluginBridge.get_plugin().cli.run_command(["sidekick", "get-version", "--namespace", namespace_selector.current_value.namespace_id])
+ if result.exit_code != 0 or !("Ok" in result.output):
+ RivetPluginBridge.display_cli_error(self, result)
+
+ OS.shell_open(result.output["Ok"]["output"])
+ _all_actions_set_disabled(false)
+
+func _on_build_deploy_button_pressed() -> void:
+ _all_actions_set_disabled(true)
+
+ var result = await RivetPluginBridge.get_plugin().cli.run_command(["sidekick", "--show-terminal", "deploy", "--namespace", namespace_selector.current_value.name_id])
+ if result.exit_code != 0:
+ RivetPluginBridge.display_cli_error(self, result)
+
+ _all_actions_set_disabled(false)
+
+func _all_actions_set_disabled(disabled: bool) -> void:
+ namespace_selector.disabled = disabled
+ manage_versions_button.disabled = disabled
+ build_deploy_button.disabled = disabled
diff --git a/godot/circles/addons/rivet/devtools/dock/deploy_tab.tscn b/godot/circles/addons/rivet/devtools/dock/deploy_tab.tscn
new file mode 100644
index 0000000..54a6f22
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/deploy_tab.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cc59f7a53362220abc3d4858dab74f6b459cd8c585ee759450b252e018031b23
+size 1840
diff --git a/godot/circles/addons/rivet/devtools/dock/dock.gd b/godot/circles/addons/rivet/devtools/dock/dock.gd
new file mode 100644
index 0000000..5eac679
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/dock.gd
@@ -0,0 +1,28 @@
+@tool extends Control
+## Mainpoint of the plugin's UI
+
+## Enum representing indexes of the children of this node
+enum Screen {
+ Login,
+ Settings,
+ Loading,
+ Installer,
+}
+
+func _ready() -> void:
+ change_current_screen(Screen.Installer)
+
+
+func reload() -> void:
+ var instance = load("res://addons/rivet/devtools/dock/dock.tscn").instantiate()
+ replace_by(instance)
+ instance.grab_focus()
+
+
+func change_current_screen(scene: Screen):
+ for idx in get_child_count():
+ var child := get_child(idx)
+ if "visible" in child:
+ child.visible = idx == scene
+ if idx == scene and child.has_method("prepare"):
+ child.prepare()
diff --git a/godot/circles/addons/rivet/devtools/dock/dock.tscn b/godot/circles/addons/rivet/devtools/dock/dock.tscn
new file mode 100644
index 0000000..e440acf
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/dock.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db5103c27568e19b20f335d96431211805427ecf621c8d4b0b8b5e6f8578e1ac
+size 1383
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/buttons_bar.gd b/godot/circles/addons/rivet/devtools/dock/elements/buttons_bar.gd
new file mode 100644
index 0000000..2a1cecd
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/buttons_bar.gd
@@ -0,0 +1,38 @@
+@tool extends HBoxContainer
+
+signal selected()
+
+@export var tab_container: TabContainer
+
+var disabled: bool = false:
+ set(value):
+ disabled = value
+ for i in get_child_count():
+ var child = get_child(i)
+ if child is Button:
+ child.disabled = disabled
+
+var current = 0
+
+func _ready() -> void:
+ for i in get_child_count():
+ var child = get_child(i)
+ if child is Button:
+ child.toggle_mode = true
+ child.pressed.connect(_select_button.bind(i))
+ if i == 0:
+ child.set_pressed_no_signal(true)
+
+func _select_button(curr: int) -> void:
+ current = curr
+ if tab_container:
+ tab_container.set_current_tab(curr)
+ for i in get_child_count():
+ var child = get_child(i)
+ if child is Button:
+ child.set_pressed_no_signal(curr==i)
+ selected.emit()
+
+
+func set_current_button(button: int) -> void:
+ _select_button(button)
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/buttons_bar.tscn b/godot/circles/addons/rivet/devtools/dock/elements/buttons_bar.tscn
new file mode 100644
index 0000000..8f93b34
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/buttons_bar.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cf33ec7b5197b987bc96ec1fc66ea82b78896b8f58581c3245a62e30fc6bfe51
+size 245
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/links_container.tscn b/godot/circles/addons/rivet/devtools/dock/elements/links_container.tscn
new file mode 100644
index 0000000..a666477
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/links_container.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f195f759d0e4967d4fd003875be75f8d912e727e698a2c27a111a260f238bdc5
+size 498
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/loading_button.gd b/godot/circles/addons/rivet/devtools/dock/elements/loading_button.gd
new file mode 100644
index 0000000..20e94c5
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/loading_button.gd
@@ -0,0 +1,32 @@
+@tool extends Button
+
+@export var loading: bool: set = _set_loading
+
+var _tween: Tween
+
+func _set_loading(value) -> void:
+ loading = value
+ disabled = value
+
+ if _tween:
+ _tween.kill()
+
+ if value:
+ _tween = get_tree().create_tween()
+
+ var icons: Array[Texture2D] = [
+ get_theme_icon("Progress1", "EditorIcons"),
+ get_theme_icon("Progress2", "EditorIcons"),
+ get_theme_icon("Progress3", "EditorIcons"),
+ get_theme_icon("Progress4", "EditorIcons"),
+ get_theme_icon("Progress5", "EditorIcons"),
+ get_theme_icon("Progress6", "EditorIcons"),
+ get_theme_icon("Progress7", "EditorIcons"),
+ get_theme_icon("Progress8", "EditorIcons"),
+ get_theme_icon("Progress9", "EditorIcons"),
+ ]
+ for idx in icons.size():
+ _tween.tween_property(self, "icon", icons[idx], 0 if idx == 0 else 1)
+ _tween.set_loops()
+ else:
+ icon = null
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/loading_button.tscn b/godot/circles/addons/rivet/devtools/dock/elements/loading_button.tscn
new file mode 100644
index 0000000..ede7093
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/loading_button.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cedd17ddf60aca4995abef9cc739c86b6b1972b98abcca36ac8f187e093ef862
+size 383
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/logo_container.gd b/godot/circles/addons/rivet/devtools/dock/elements/logo_container.gd
new file mode 100644
index 0000000..d253415
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/logo_container.gd
@@ -0,0 +1,9 @@
+@tool extends HBoxContainer
+
+@onready var logo: TextureRect = %Logo
+var logo_dark = preload("../../../images/icon-text-black.svg")
+var logo_light = preload("../../../images/icon-text-white.svg")
+
+func _ready() -> void:
+ var is_dark = get_theme_color("font_color", "Editor").get_luminance() < 0.5
+ logo.texture = logo_dark if is_dark else logo_light
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/logo_container.tscn b/godot/circles/addons/rivet/devtools/dock/elements/logo_container.tscn
new file mode 100644
index 0000000..1c5b34e
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/logo_container.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40ed8e33ecc4ec11b5260a0d1c23e442d1fe0c6dbd9d834e878242b59f47166a
+size 671
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/namespace_menu_button.gd b/godot/circles/addons/rivet/devtools/dock/elements/namespace_menu_button.gd
new file mode 100644
index 0000000..256ac6a
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/namespace_menu_button.gd
@@ -0,0 +1,31 @@
+@tool extends OptionButton
+## A control that displays a list of namespaces and allows the user to select one.
+
+@export var current_value: Dictionary
+
+var namespaces: Array:
+ get: return RivetPluginBridge.instance.game_namespaces
+
+func _ready():
+ if RivetPluginBridge.is_part_of_edited_scene(self):
+ return
+ disabled = true
+ _update_menu_button(namespaces)
+ item_selected.connect(_on_item_selected)
+ RivetPluginBridge.instance.bootstrapped.connect(_on_plugin_bootstrapped)
+
+func _update_menu_button(value: Array) -> void:
+ clear()
+ for i in value.size():
+ add_item("%s (v%s)" % [namespaces[i].display_name, namespaces[i].version.display_name], i)
+
+func _on_item_selected(idx: int):
+ _select_menu_item(idx)
+
+func _select_menu_item(idx: int) -> void:
+ current_value = namespaces[idx]
+
+func _on_plugin_bootstrapped() -> void:
+ disabled = false
+ _update_menu_button(namespaces)
+ _select_menu_item(0)
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/dock/elements/namespace_menu_button.tscn b/godot/circles/addons/rivet/devtools/dock/elements/namespace_menu_button.tscn
new file mode 100644
index 0000000..d1ce660
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/elements/namespace_menu_button.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5a058e1f9c5c18dcdc8bd06d84477bb270ef8700fd981d45e8750f882a8f2124
+size 375
diff --git a/godot/circles/addons/rivet/devtools/dock/installer.gd b/godot/circles/addons/rivet/devtools/dock/installer.gd
new file mode 100644
index 0000000..7c13b86
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/installer.gd
@@ -0,0 +1,37 @@
+@tool extends Control
+
+@onready var InstallButton: Button = %InstallButton
+@onready var InstallDialog: AcceptDialog = %InstallDialog
+@onready var InstallLabel: RichTextLabel = %InstallLabel
+
+func prepare() -> void:
+ InstallLabel.add_theme_font_override(&"mono_font", get_theme_font(&"output_source_mono", &"EditorFonts"))
+ InstallLabel.add_theme_font_override(&"bold_font", get_theme_font(&"bold", &"EditorFonts"))
+ InstallLabel.add_theme_stylebox_override(&"normal", get_theme_stylebox(&"bg", &"AssetLib"))
+
+ InstallLabel.text = InstallLabel.text.replace(&"%%version%%", RivetPluginBridge.get_plugin().cli.REQUIRED_RIVET_CLI_VERSION).replace(&"%%bin_dir%%", RivetPluginBridge.get_plugin().cli.get_bin_dir())
+ InstallButton.loading = true
+ var error = await RivetPluginBridge.get_plugin().cli.check_existence()
+ if error:
+ InstallButton.loading = false
+ return
+ owner.change_current_screen(owner.Screen.Login)
+
+func _ready() -> void:
+ InstallButton.pressed.connect(_on_install_button_pressed)
+
+func _on_install_button_pressed() -> void:
+ InstallButton.loading = true
+ var result = await RivetPluginBridge.get_plugin().cli.install()
+ if result.exit_code == 0:
+ var error = await RivetPluginBridge.get_plugin().cli.check_existence()
+ if not error:
+ InstallDialog.title = &"Success!"
+ InstallDialog.dialog_text = &"Rivet installed successfully!\nInstalled Rivet %s in %s" % [RivetPluginBridge.get_plugin().cli.REQUIRED_RIVET_CLI_VERSION, RivetPluginBridge.get_plugin().cli.get_bin_dir()]
+ InstallDialog.popup_centered()
+ owner.change_current_screen(owner.Screen.Login)
+ return
+ InstallDialog.title = &"Error!"
+ InstallDialog.dialog_text = &"Rivet installation failed! Please try again.\n\n%s" % result.output
+ InstallDialog.popup_centered()
+ InstallButton.loading = false
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/dock/installer.tscn b/godot/circles/addons/rivet/devtools/dock/installer.tscn
new file mode 100644
index 0000000..1fd15ee
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/installer.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1aa7a2556124445ad68f63b39037bfae4d557c1ff2f2e7ebc61e837499c08c40
+size 2039
diff --git a/godot/circles/addons/rivet/devtools/dock/loading.gd b/godot/circles/addons/rivet/devtools/dock/loading.gd
new file mode 100644
index 0000000..ab2136e
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/loading.gd
@@ -0,0 +1,9 @@
+extends VBoxContainer
+
+
+func _ready() -> void:
+ %CancelButton.pressed.connect(_on_cancel_button_pressed)
+
+func _on_cancel_button_pressed() -> void:
+ # TODO(forest): cancel cli command
+ owner.change_current_screen(owner.Screen.Login)
diff --git a/godot/circles/addons/rivet/devtools/dock/loading.tscn b/godot/circles/addons/rivet/devtools/dock/loading.tscn
new file mode 100644
index 0000000..b088e51
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/loading.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fc2f108bb4344679e1d34c41b96af0d4293178de20dad872beb51a9db4a8de71
+size 1338
diff --git a/godot/circles/addons/rivet/devtools/dock/login.gd b/godot/circles/addons/rivet/devtools/dock/login.gd
new file mode 100644
index 0000000..7a29fa4
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/login.gd
@@ -0,0 +1,62 @@
+@tool extends Control
+## A button that logs the user in to the Rivet using Rivet CLI.
+
+@onready var log_in_button: Button = %LogInButton
+@onready var api_endpoint_line_edit: LineEdit = %ApiEndpointLineEdit
+@onready var advanced_options_button: Button = %AdvancedOptionsButton
+@onready var api_endpoint_field: Control = %ApiEndpointField
+
+func prepare() -> void:
+ var result = await RivetPluginBridge.get_plugin().cli.run_command([
+ "sidekick",
+ "check-login-state",
+ ])
+ if result.exit_code == result.ExitCode.SUCCESS and "Ok" in result.output:
+ owner.change_current_screen(owner.Screen.Settings)
+ return
+
+func _ready():
+ log_in_button.pressed.connect(_on_button_pressed)
+ advanced_options_button.pressed.connect(_on_advanced_options_button_pressed)
+ advanced_options_button.icon = get_theme_icon("arrow", "OptionButton")
+
+func _on_button_pressed() -> void:
+ log_in_button.disabled = true
+ var api_address = api_endpoint_line_edit.text
+ var result = await RivetPluginBridge.get_plugin().cli.run_command([
+ "--api-endpoint",
+ api_address,
+ "sidekick",
+ "get-link",
+ ])
+ if result.exit_code != result.ExitCode.SUCCESS or !("Ok" in result.output):
+ RivetPluginBridge.display_cli_error(self, result)
+ log_in_button.disabled = false
+ return
+ var data: Dictionary = result.output["Ok"]
+
+ # Now that we have the link, open it in the user's browser
+ OS.shell_open(data["device_link_url"])
+
+ owner.change_current_screen(owner.Screen.Loading)
+
+ # Long-poll the Rivet API until the user has logged in
+ result = await RivetPluginBridge.get_plugin().cli.run_command([
+ "--api-endpoint",
+ api_address,
+ "sidekick",
+ "wait-for-login",
+ "--device-link-token",
+ data["device_link_token"],
+ ])
+
+ if result.exit_code != result.ExitCode.SUCCESS or !("Ok" in result.output):
+ RivetPluginBridge.display_cli_error(self, result)
+ log_in_button.disabled = false
+ return
+
+ log_in_button.disabled = false
+ owner.change_current_screen(owner.Screen.Settings)
+
+func _on_advanced_options_button_pressed():
+ api_endpoint_field.visible = !api_endpoint_field.visible
diff --git a/godot/circles/addons/rivet/devtools/dock/login.tscn b/godot/circles/addons/rivet/devtools/dock/login.tscn
new file mode 100644
index 0000000..6eec717
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/login.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:50c720e4c5c0bafc7ada952ad8eae6a5aee0bc9e86b223a94f70861b142ba801
+size 4571
diff --git a/godot/circles/addons/rivet/devtools/dock/playtest_tab.gd b/godot/circles/addons/rivet/devtools/dock/playtest_tab.gd
new file mode 100644
index 0000000..f00f5d4
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/playtest_tab.gd
@@ -0,0 +1,113 @@
+@tool extends MarginContainer
+
+const ButtonsBar = preload("elements/buttons_bar.gd")
+
+@onready var namespace_description: RichTextLabel = %NamespaceDescription
+@onready var buttons_bar: ButtonsBar = %ButtonsBar
+@onready var warning: RichTextLabel = %WarningLabel
+@onready var error: RichTextLabel = %ErrorLabel
+@onready var deploy_button: Button = %DeployButton
+@onready var namespace_selector = %AuthNamespaceSelector
+
+func _ready() -> void:
+ if get_tree().edited_scene_root == self:
+ return # This is the scene opened in the editor!
+ namespace_description.add_theme_font_override(&"mono_font", get_theme_font(&"output_source_mono", &"EditorFonts"))
+ namespace_description.add_theme_font_override(&"bold_font", get_theme_font(&"bold", &"EditorFonts"))
+ namespace_description.add_theme_stylebox_override(&"normal", get_theme_stylebox(&"bg", &"AssetLib"))
+ namespace_description.meta_clicked.connect(func(meta): OS.shell_open(str(meta)))
+
+ warning.add_theme_color_override(&"default_color", get_theme_color(&"warning_color", &"Editor"))
+ warning.add_theme_stylebox_override(&"normal", get_theme_stylebox(&"bg", &"AssetLib"))
+ var warning_text = warning.text
+ warning.text = ""
+ warning.add_image(get_theme_icon("StatusWarning", "EditorIcons"))
+ warning.add_text(warning_text)
+
+ error.add_theme_color_override(&"default_color", get_theme_color("error_color", "Editor"))
+ error.add_theme_stylebox_override(&"normal", get_theme_stylebox(&"bg", &"AssetLib"))
+ var error_text = error.text
+ error.text = ""
+ error.add_image(get_theme_icon("StatusError", "EditorIcons"))
+ error.add_text(error_text)
+
+ warning.visible = false
+ error.visible = false
+ deploy_button.visible = false
+
+ RivetPluginBridge.instance.bootstrapped.connect(_on_bootstrapped)
+ namespace_selector.item_selected.connect(_on_namespace_selector_item_selected)
+ deploy_button.pressed.connect(_on_deploy_button_pressed)
+ buttons_bar.selected.connect(_on_buttons_bar_selected)
+
+func _on_namespace_selector_item_selected(id: int) -> void:
+ _update_warnings()
+
+func _on_buttons_bar_selected() -> void:
+ _update_warnings()
+
+func _on_bootstrapped() -> void:
+ _update_warnings()
+
+func _update_warnings() -> void:
+ var is_local_machine = buttons_bar.current == 0
+ var is_online_server = buttons_bar.current == 1
+ var current_namespace = namespace_selector.current_value
+
+ # Local machine
+ if is_local_machine:
+ warning.visible = false
+ error.visible = false
+ deploy_button.visible = false
+ _generate_dev_auth_token(current_namespace)
+ return
+
+ # Online server
+ if is_online_server:
+ # It means that user hasn't deployed anything to this namespace yet
+ if current_namespace.version.display_name == "0.0.1":
+ warning.visible = false
+ error.visible = true
+ deploy_button.visible = true
+ else:
+ warning.visible = true
+ error.visible = false
+ deploy_button.visible = false
+ _generate_public_auth_token(current_namespace)
+ return
+
+func _all_actions_set_disabled(disabled: bool) -> void:
+ namespace_selector.disabled = disabled
+ buttons_bar.disabled = disabled
+
+func _generate_dev_auth_token(ns) -> void:
+ _actions_disabled_while(func():
+ var result = await RivetPluginBridge.get_plugin().cli.run_command(["sidekick", "get-namespace-development-token", "--namespace", ns.name_id])
+ if result.exit_code != 0 or !("Ok" in result.output):
+ RivetPluginBridge.display_cli_error(self, result)
+ return
+
+ RivetPluginBridge.get_plugin().namespace_token = result.output["Ok"]["token"]
+ RivetPluginBridge.instance.save_configuration()
+ )
+
+func _generate_public_auth_token(ns) -> void:
+ _actions_disabled_while(func():
+ var result = await RivetPluginBridge.get_plugin().cli.run_command(["sidekick", "get-namespace-public-token", "--namespace", ns.name_id])
+ if result.exit_code != 0 or !("Ok" in result.output):
+ RivetPluginBridge.display_cli_error(self, result)
+ return
+
+ RivetPluginBridge.get_plugin().namespace_token = result.output["Ok"]["token"]
+ RivetPluginBridge.instance.save_configuration()
+ )
+
+func _actions_disabled_while(fn: Callable) -> void:
+ _all_actions_set_disabled(true)
+ await fn.call()
+ _all_actions_set_disabled(false)
+
+func _on_deploy_button_pressed() -> void:
+ owner.change_tab(1)
+ owner.deploy_tab.namespace_selector.current_value = namespace_selector.current_value
+ owner.deploy_tab.namespace_selector.selected = namespace_selector.selected
diff --git a/godot/circles/addons/rivet/devtools/dock/playtest_tab.tscn b/godot/circles/addons/rivet/devtools/dock/playtest_tab.tscn
new file mode 100644
index 0000000..ca1e911
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/playtest_tab.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:35d858c11865b18cf58f396cf6cb29496b06c631ebb369ed96686246576c06af
+size 2779
diff --git a/godot/circles/addons/rivet/devtools/dock/settings.gd b/godot/circles/addons/rivet/devtools/dock/settings.gd
new file mode 100644
index 0000000..3ff0aa1
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/settings.gd
@@ -0,0 +1,15 @@
+@tool extends Control
+## Settings screens allow you to configure and deploy your game.
+
+@onready var errorDialog: AcceptDialog = %ErrorDialog
+@onready var buttons_bar: HBoxContainer = %ButtonsBar
+@onready var deploy_tab = %Deploy
+
+func prepare():
+ var error = await RivetPluginBridge.instance.bootstrap()
+ if error:
+ errorDialog.popup_centered()
+ return
+
+func change_tab(tab: int):
+ buttons_bar.set_current_button(tab)
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/dock/settings.tscn b/godot/circles/addons/rivet/devtools/dock/settings.tscn
new file mode 100644
index 0000000..e1780a8
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/settings.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:630988a52df20551ce8ab7ad69175422ca0dced601e1b3c0ccc46c745ff240aa
+size 2802
diff --git a/godot/circles/addons/rivet/devtools/dock/settings_tab.gd b/godot/circles/addons/rivet/devtools/dock/settings_tab.gd
new file mode 100644
index 0000000..cd0660d
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/settings_tab.gd
@@ -0,0 +1,24 @@
+@tool extends Control
+
+@onready var unlink_game_button: Button = %UnlinkGameButton
+
+
+func _ready() -> void:
+ unlink_game_button.pressed.connect(_on_unlink_game_button_pressed)
+
+
+func _on_unlink_game_button_pressed() -> void:
+ unlink_game_button.disabled = true
+
+ var result = await RivetPluginBridge.get_plugin().cli.run_command([
+ "unlink"
+ ])
+
+ if result.exit_code != result.ExitCode.SUCCESS:
+ RivetPluginBridge.display_cli_error(self, result)
+ unlink_game_button.disabled = false
+ return
+
+ unlink_game_button.disabled = false
+ owner.owner.reload()
+ owner.owner.change_current_screen(owner.owner.Screen.Login)
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/dock/settings_tab.tscn b/godot/circles/addons/rivet/devtools/dock/settings_tab.tscn
new file mode 100644
index 0000000..8342390
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/dock/settings_tab.tscn
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55d3f65bac202d3f0022fe5357cab88c4946d6b6abc953edc9f1f834b5925af0
+size 390
diff --git a/godot/circles/addons/rivet/devtools/rivet_cli.gd b/godot/circles/addons/rivet/devtools/rivet_cli.gd
new file mode 100644
index 0000000..3be050c
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/rivet_cli.gd
@@ -0,0 +1,76 @@
+extends RefCounted
+## Wrapper aroudn the Rivet CLI, allowing you to run it from GDScript in non-blocking way, and get the output.
+##
+## @experimental
+
+const REQUIRED_RIVET_CLI_VERSION = "v1.0.0"
+
+const _RivetEditorSettings = preload("rivet_editor_settings.gd")
+const _RivetThread = preload("rivet_thread.gd")
+const _RivetCliOutput = preload("rivet_cli_output.gd")
+
+func check_existence() -> Error:
+ var editor_rivet_path = _RivetEditorSettings.get_setting(_RivetEditorSettings.RIVET_CLI_PATH_SETTING.name)
+ if not editor_rivet_path or editor_rivet_path.is_empty():
+ return FAILED
+ var result: _RivetCliOutput = await run_command(["sidekick", "get-cli-version"])
+ if result.exit_code != 0 or !("Ok" in result.output):
+ return FAILED
+ var cli_version = result.output["Ok"].version
+ if cli_version != REQUIRED_RIVET_CLI_VERSION:
+ return FAILED
+ return OK
+
+func run_command(args: PackedStringArray) -> _RivetCliOutput:
+ var thread: _RivetThread = _RivetThread.new(_run.bind(args))
+ return await thread.wait_to_finish()
+
+func get_bin_dir() -> String:
+ var home_path: String = OS.get_environment("HOME")
+ return home_path.path_join(".rivet").path_join(REQUIRED_RIVET_CLI_VERSION).path_join("bin")
+
+func get_cli_path() -> String:
+ var cli_path = _RivetEditorSettings.get_setting(_RivetEditorSettings.RIVET_CLI_PATH_SETTING.name)
+ if cli_path and !cli_path.is_empty():
+ return cli_path
+ return get_bin_dir().path_join("rivet.exe" if OS.get_name() == "Windows" else "rivet")
+
+func install() -> _RivetCliOutput:
+ var thread: _RivetThread = _RivetThread.new(_install)
+ var result = await thread.wait_to_finish()
+ if result.exit_code == 0:
+ _RivetEditorSettings.set_setting_value(_RivetEditorSettings.RIVET_CLI_PATH_SETTING.name, get_bin_dir())
+ return result
+
+
+## region Internal functions
+
+## Runs Rivet CLI with given arguments.
+func _run(args: PackedStringArray) -> _RivetCliOutput:
+ var output = []
+ RivetPluginBridge.log(["Running Rivet CLI: ", "%s %s" % [get_cli_path(), " ".join(args)]])
+ var code: int = OS.execute(get_cli_path(), args, output, true)
+
+ return _RivetCliOutput.new(code, output)
+
+func _install() -> _RivetCliOutput:
+ var output = []
+ var code: int
+ var bin_dir: String = get_bin_dir()
+
+ OS.set_environment("RIVET_CLI_VERSION", REQUIRED_RIVET_CLI_VERSION)
+ OS.set_environment("BIN_DIR", bin_dir)
+
+ # Double quotes issue: https://github.com/godotengine/godot/issues/37291#issuecomment-603821838
+ if OS.get_name() == "Windows":
+ var args = ["-Commandi", "\"'iwr https://raw.githubusercontent.com/rivet-gg/cli/$env:RIVET_CLI_VERSION/install/windows.ps1 -useb | iex'\""]
+ code = OS.execute("powershell.exe", args, output, true, true)
+ else:
+ #var args = ["-c", "\"'curl -fsSL https://raw.githubusercontent.com/rivet-gg/cli/${RIVET_CLI_VERSION}/install/unix.sh | sh''\""]
+ var args = ["-c", "\"'curl -fsSL https://raw.githubusercontent.com/rivet-gg/cli/ac57796861d195230fa043e12c5f9fe1921f467f/install/unix.sh | sh'\""]
+ code = OS.execute("/bin/sh", args, output, true, true)
+ return _RivetCliOutput.new(code, output)
+
+
+
+## endregion
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/rivet_cli_output.gd b/godot/circles/addons/rivet/devtools/rivet_cli_output.gd
new file mode 100644
index 0000000..889499e
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/rivet_cli_output.gd
@@ -0,0 +1,38 @@
+extends RefCounted
+## It's a wrapper for the output of the command line tools
+
+var exit_code: ExitCode
+var output: Dictionary
+
+## The exit code of the command line tool
+enum ExitCode {
+ SUCCESS = 0
+ # TODO: fill with the rest of the exit codes
+}
+
+func _init(exit_code: int, internal_output: Array) -> void:
+ self.exit_code = exit_code
+
+ if internal_output and not internal_output.is_empty():
+ _parse_output(internal_output)
+
+func _parse_output(internal_output: Array) -> void:
+ var lines_with_json = internal_output.filter(
+ func (line: String):
+ return line.find("{") != -1
+ )
+
+ if lines_with_json.is_empty():
+ print("No JSON output found")
+ return
+
+ var line_with_json: String = lines_with_json.front()
+ # Parse the output as JSON
+ var json: JSON = JSON.new()
+ var error: Error = json.parse(line_with_json)
+
+ if error == OK:
+ self.output = json.data
+ else:
+ # If the output is not JSON, throw an error
+ RivetPluginBridge.error("Invalid response from the command line tool: " + str(error))
diff --git a/godot/circles/addons/rivet/devtools/rivet_editor_settings.gd b/godot/circles/addons/rivet/devtools/rivet_editor_settings.gd
new file mode 100644
index 0000000..1517395
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/rivet_editor_settings.gd
@@ -0,0 +1,28 @@
+const RIVET_CLI_PATH_SETTING = {
+ "name": "rivet/cli_executable_path",
+ "type": TYPE_STRING,
+}
+const RIVET_DEBUG_SETTING ={
+ "name": "rivet/debug",
+ "type": TYPE_BOOL,
+}
+
+## Returns the path to the Rivet CLI executable stored in the editor settings.
+static func set_defaults(settings: EditorSettings = EditorInterface.get_editor_settings()) -> void:
+ set_default_setting_value(RIVET_CLI_PATH_SETTING["name"], "", settings)
+ settings.add_property_info(RIVET_CLI_PATH_SETTING)
+ set_default_setting_value(RIVET_DEBUG_SETTING["name"], false, settings)
+ settings.add_property_info(RIVET_DEBUG_SETTING)
+
+## Sets the path to the Rivet CLI executable in the editor settings, if it is not already set.
+static func set_default_setting_value(name: String, default_value: Variant, settings: EditorSettings = EditorInterface.get_editor_settings()) -> void:
+ var existing_value = settings.get_setting(name)
+ settings.set_initial_value(name, default_value, false)
+ settings.set_setting(name, existing_value if existing_value else default_value)
+
+static func set_setting_value(name: String, value: Variant, settings: EditorSettings = EditorInterface.get_editor_settings()) -> void:
+ settings.set_setting(name, value)
+
+## Returns the path to the Rivet CLI executable stored in the editor settings.
+static func get_setting(name: String, settings: EditorSettings = EditorInterface.get_editor_settings()) -> Variant:
+ return settings.get_setting(name)
diff --git a/godot/circles/addons/rivet/devtools/rivet_export_plugin.gd b/godot/circles/addons/rivet/devtools/rivet_export_plugin.gd
new file mode 100644
index 0000000..bbb7419
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/rivet_export_plugin.gd
@@ -0,0 +1,29 @@
+@tool
+extends EditorExportPlugin
+
+var has_added_file: bool = false
+var _plugin_name = "RivetEditorPlugin"
+
+func _supports_platform(platform):
+ return true
+
+func _get_name():
+ return _plugin_name
+
+func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
+ if not FileAccess.file_exists(RivetPluginBridge.RIVET_CONFIGURATION_FILE_PATH):
+ push_warning("Rivet plugin not configured. Please configure it using plugin interface.")
+ return
+
+ var configuration_file = FileAccess.open(RivetPluginBridge.RIVET_CONFIGURATION_FILE_PATH, FileAccess.READ)
+ var source = configuration_file.get_as_text()
+ var script = GDScript.new()
+ script.source_code = source
+ var error: Error = ResourceSaver.save(script, RivetPluginBridge.RIVET_DEPLOYED_CONFIGURATION_FILE_PATH)
+ if not error:
+ has_added_file = true
+
+
+func _export_end() -> void:
+ if has_added_file:
+ DirAccess.remove_absolute(RivetPluginBridge.RIVET_DEPLOYED_CONFIGURATION_FILE_PATH)
\ No newline at end of file
diff --git a/godot/circles/addons/rivet/devtools/rivet_plugin_bridge.gd b/godot/circles/addons/rivet/devtools/rivet_plugin_bridge.gd
new file mode 100644
index 0000000..018e3b3
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/rivet_plugin_bridge.gd
@@ -0,0 +1,128 @@
+@tool class_name RivetPluginBridge
+## Scaffolding for the plugin to be used in the editor, this is not meant to be
+## used in the game. It's a way to get the plugin instance from the engine's
+## perspective.
+##
+## @experimental
+
+signal bootstrapped
+
+const RIVET_CONFIGURATION_PATH: String = "res://.rivet"
+const RIVET_CONFIGURATION_FILE_PATH: String = "res://.rivet/config.gd"
+const RIVET_DEPLOYED_CONFIGURATION_FILE_PATH: String = "res://.rivet_config.gd"
+const SCRIPT_TEMPLATE: String = """
+extends RefCounted
+const api_endpoint: String = \"{api_endpoint}\"
+const namespace_token: String = \"{namespace_token}\"
+const cloud_token: String = \"{cloud_token}\"
+const game_id: String = \"{game_id}\"
+"""
+const _global := preload("../rivet_global.gd")
+const _RivetEditorSettings = preload("./rivet_editor_settings.gd")
+
+static var game_namespaces: Array
+
+static var instance = RivetPluginBridge.new()
+
+static func _find_plugin():
+ var tree: SceneTree = Engine.get_main_loop()
+ return tree.get_root().get_child(0).get_node_or_null("RivetPlugin")
+
+static func display_cli_error(node: Node, cli_output) -> AcceptDialog:
+ var error = cli_output.output["Err"].c_unescape() if "Err" in cli_output.output else "\n".join(cli_output.formatted_output)
+ var alert = AcceptDialog.new()
+ alert.title = "Error!"
+ alert.dialog_text = error
+ alert.dialog_autowrap = true
+ alert.close_requested.connect(func(): alert.queue_free() )
+ node.add_child(alert)
+ alert.popup_centered_ratio(0.4)
+ return alert
+
+# https://github.com/godotengine/godot-proposals/issues/900#issuecomment-1812881718
+static func is_part_of_edited_scene(node: Node):
+ return Engine.is_editor_hint() && node.is_inside_tree() && node.get_tree().get_edited_scene_root() && (node.get_tree().get_edited_scene_root() == node || node.get_tree().get_edited_scene_root().is_ancestor_of(node))
+
+## Autoload is not available for editor interfaces, we add a scoffolding to get
+## the instance of the plugin from the engine's perspective
+## @experimental
+static func get_plugin() -> _global:
+ var plugin = _find_plugin()
+ if plugin:
+ return plugin.global
+ return null
+
+static func log(args):
+ if _RivetEditorSettings.get_setting(_RivetEditorSettings.RIVET_DEBUG_SETTING.name):
+ print("[Rivet] ", args)
+
+static func warning(args):
+ push_warning("[Rivet] ", args)
+
+static func error(args):
+ push_error("[Rivet] ", args)
+
+func save_configuration():
+ DirAccess.make_dir_recursive_absolute(RIVET_CONFIGURATION_PATH)
+
+ var gd_ignore_path = RIVET_CONFIGURATION_PATH.path_join(".gdignore")
+ if not FileAccess.file_exists(gd_ignore_path):
+ var gd_ignore = FileAccess.open(gd_ignore_path, FileAccess.WRITE)
+ gd_ignore.store_string("")
+
+ var git_ignore_path = RIVET_CONFIGURATION_PATH.path_join(".gitignore")
+ if not FileAccess.file_exists(git_ignore_path):
+ var git_ignore = FileAccess.open(git_ignore_path, FileAccess.WRITE)
+ git_ignore.store_string("*")
+
+ var plg = get_plugin()
+ var script: GDScript = GDScript.new()
+ script.source_code = SCRIPT_TEMPLATE.format({"api_endpoint": plg.api_endpoint, "namespace_token": plg.namespace_token, "cloud_token": plg.cloud_token, "game_id": plg.game_id})
+ var err: Error = ResourceSaver.save(script, RIVET_CONFIGURATION_FILE_PATH)
+ if err:
+ push_warning("Error saving Rivet data: %s" % err)
+
+func bootstrap() -> Error:
+ var plugin = get_plugin()
+ if not plugin:
+ return FAILED
+
+ var result = await get_plugin().cli.run_command([
+ "sidekick",
+ "get-bootstrap-data",
+ ])
+
+ if result.exit_code != 0 or !("Ok" in result.output):
+ return FAILED
+
+ get_plugin().api_endpoint = result.output["Ok"].api_endpoint
+ get_plugin().cloud_token = result.output["Ok"].token
+ get_plugin().game_id = result.output["Ok"].game_id
+
+ save_configuration()
+
+ var fetch_result = await _fetch_plugin_data()
+ if fetch_result == OK:
+ emit_signal("bootstrapped")
+ return fetch_result
+
+func _fetch_plugin_data() -> Error:
+ var response = await get_plugin().GET("/cloud/games/%s" % get_plugin().game_id).wait_completed()
+ # response.body:
+ # game.namespaces = {namespace_id, version_id, display_name}[]
+ # game.versions = {version_id, display_name}[]
+ if response.response_code != HTTPClient.ResponseCode.RESPONSE_OK:
+ return FAILED
+
+ var namespaces = response.body.game.namespaces
+ for space in namespaces:
+ var versions: Array = response.body.game.versions.filter(
+ func (version): return version.version_id == space.version_id
+ )
+ if versions.is_empty():
+ space["version"] = null
+ else:
+ space["version"] = versions[0]
+
+ game_namespaces = namespaces
+ return OK
diff --git a/godot/circles/addons/rivet/devtools/rivet_thread.gd b/godot/circles/addons/rivet/devtools/rivet_thread.gd
new file mode 100644
index 0000000..2f181f0
--- /dev/null
+++ b/godot/circles/addons/rivet/devtools/rivet_thread.gd
@@ -0,0 +1,29 @@
+extends RefCounted
+## A wrapper around Thread that allows you to wait for the thread to finish and get the result.
+##
+## @experimental
+
+signal finished(output: Variant)
+
+var _mutex: Mutex
+var _thread: Thread
+
+## Result of the thread.
+var output: Variant = null
+
+## Returns the output of the thread.
+func wait_to_finish():
+ await finished
+ return output
+
+func _init(fn: Callable) -> void:
+ _thread = Thread.new()
+ _mutex = Mutex.new()
+ _thread.start(func():
+ var result = fn.call()
+ _mutex.lock()
+ output = result
+ call_deferred("emit_signal", "finished", result)
+ _mutex.unlock()
+ return result
+ )
diff --git a/godot/circles/addons/rivet/images/icon-circle.png.import b/godot/circles/addons/rivet/images/icon-circle.png.import
new file mode 100644
index 0000000..29517e9
--- /dev/null
+++ b/godot/circles/addons/rivet/images/icon-circle.png.import
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a4fe33ce77fa52db4e84e161f67a04fb69f7333444a70be68009447d35295066
+size 787
diff --git a/godot/circles/addons/rivet/images/icon-text-black.svg b/godot/circles/addons/rivet/images/icon-text-black.svg
new file mode 100644
index 0000000..14f0fe0
--- /dev/null
+++ b/godot/circles/addons/rivet/images/icon-text-black.svg
@@ -0,0 +1,9 @@
+
diff --git a/godot/circles/addons/rivet/images/icon-text-black.svg.import b/godot/circles/addons/rivet/images/icon-text-black.svg.import
new file mode 100644
index 0000000..102d6ef
--- /dev/null
+++ b/godot/circles/addons/rivet/images/icon-text-black.svg.import
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fe3c270c3395b9324d775d72ca82b26448c8f2e19174e47587c48ce359974955
+size 895
diff --git a/godot/circles/addons/rivet/images/icon-text-white.svg b/godot/circles/addons/rivet/images/icon-text-white.svg
new file mode 100644
index 0000000..1807618
--- /dev/null
+++ b/godot/circles/addons/rivet/images/icon-text-white.svg
@@ -0,0 +1,9 @@
+
diff --git a/godot/circles/addons/rivet/images/icon-text-white.svg.import b/godot/circles/addons/rivet/images/icon-text-white.svg.import
new file mode 100644
index 0000000..d91ef5a
--- /dev/null
+++ b/godot/circles/addons/rivet/images/icon-text-white.svg.import
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:684547448294b6e186953ce9a56b565fa4c941dc17fadf196923148964821f13
+size 896
diff --git a/godot/circles/addons/rivet/images/white-logo.svg.import b/godot/circles/addons/rivet/images/white-logo.svg.import
new file mode 100644
index 0000000..be20926
--- /dev/null
+++ b/godot/circles/addons/rivet/images/white-logo.svg.import
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e412fc328b9960853c19fe1998527052651f0db4a58f1964643b0cfefca4dd4d
+size 881
diff --git a/godot/circles/addons/rivet/plugin.cfg b/godot/circles/addons/rivet/plugin.cfg
new file mode 100644
index 0000000..79be32c
--- /dev/null
+++ b/godot/circles/addons/rivet/plugin.cfg
@@ -0,0 +1,8 @@
+[plugin]
+
+name="Rivet API"
+description=""
+author="Rivet Gaming, Inc."
+version="1.0.0-rc.1"
+script="rivet.gd"
+
diff --git a/godot/circles/addons/rivet/rivet.gd b/godot/circles/addons/rivet/rivet.gd
new file mode 100644
index 0000000..db0d56c
--- /dev/null
+++ b/godot/circles/addons/rivet/rivet.gd
@@ -0,0 +1,58 @@
+@tool extends EditorPlugin
+## Mainpoint for the Rivet editor plugin.
+
+# MARK: Plugin
+const AUTO_LOAD_RIVET_CLIENT = "RivetClient"
+const AUTO_LOAD_RIVET_HELPER = "RivetHelper"
+const AUTO_LOAD_RIVET_GLOBAL = "Rivet"
+
+const _RivetEditorSettings := preload("devtools/rivet_editor_settings.gd")
+const _RivetGlobal := preload("rivet_global.gd")
+const _RivetCLI = preload("devtools/rivet_cli.gd")
+
+var _dock: Control
+var _export_plugin: EditorExportPlugin
+var cli: _RivetCLI = _RivetCLI.new()
+
+## The global singleton for the Rivet plugin, only available in the editor.
+var global: _RivetGlobal
+
+func _init() -> void:
+ name = "RivetPlugin"
+
+func _enter_tree():
+ # Add singleton
+ add_autoload_singleton(AUTO_LOAD_RIVET_CLIENT, "rivet_client.gd")
+ add_autoload_singleton(AUTO_LOAD_RIVET_HELPER, "rivet_helper.gd")
+
+ add_autoload_singleton(AUTO_LOAD_RIVET_GLOBAL, "rivet_global.gd")
+
+ global = _RivetGlobal.new()
+ global.cli = cli
+
+ _dock = preload("devtools/dock/dock.tscn").instantiate()
+ _dock.add_child(global)
+
+ # Add export plugin
+ _export_plugin = preload("devtools/rivet_export_plugin.gd").new()
+ add_export_plugin(_export_plugin)
+
+ # Add dock
+ add_control_to_dock(DOCK_SLOT_LEFT_BR, _dock)
+ _RivetEditorSettings.set_defaults()
+
+
+func _exit_tree():
+ # Remove singleton
+ remove_autoload_singleton(AUTO_LOAD_RIVET_CLIENT)
+ remove_autoload_singleton(AUTO_LOAD_RIVET_HELPER)
+ remove_autoload_singleton(AUTO_LOAD_RIVET_GLOBAL)
+
+ # Remove export plugin
+ remove_export_plugin(_export_plugin)
+ _export_plugin = null
+
+ # Remove dock
+ remove_control_from_docks(_dock)
+ _dock.free()
+
diff --git a/godot/circles/addons/rivet/rivet.version.toml.tpl b/godot/circles/addons/rivet/rivet.version.toml.tpl
new file mode 100644
index 0000000..0916519
--- /dev/null
+++ b/godot/circles/addons/rivet/rivet.version.toml.tpl
@@ -0,0 +1,52 @@
+# === Rivet Version Configuration ===
+#
+# - More info: https://docs.rivet.gg/general/concepts/rivet-version-config
+# - Reference: https://docs.rivet.gg/cloud/api/post-games-versions#body
+# - Publish a new version with `rivet publish`
+#
+
+# How the game lobbies run and how players connect to the game.
+#
+# https://docs.rivet.gg/matchmaker/introduction
+[matchmaker]
+ # How many players can join a specific lobby.
+ #
+ # Read more about matchmaking: https://docs.rivet.gg/matchmaker/concepts/finding-lobby
+ max_players = 32
+
+ # The hardware to provide for lobbies.
+ #
+ # Available tiers: https://docs.rivet.gg/serverless-lobbies/concepts/available-tiers
+ tier = "basic-1d1"
+
+# Which regions the game should be available in.
+#
+# Available regions: https://docs.rivet.gg/serverless-lobbies/concepts/available-regions
+[matchmaker.regions]
+ lnd-sfo = {}
+ lnd-fra = {}
+
+# Runtime configuration for the lobby's Docker container.
+[matchmaker.docker]
+ # If you're unfamiliar with Docker, here's how to write your own
+ # Dockerfile:
+ # https://docker-curriculum.com/#dockerfile
+ dockerfile = "Dockerfile"
+
+ # Which ports to allow players to connect to. Multiple ports can be defined
+ # with different protocols.
+ #
+ # How ports work: https://docs.rivet.gg/serverless-lobbies/concepts/ports
+ ports.default = { port = 10567, protocol = "udp" }
+
+# What game modes are avaiable.
+#
+# Properties like `max_players`, `tier`, `dockerfile`, `regions`, and more can
+# be overriden for specific game modes.
+[matchmaker.game_modes]
+ default = {}
+
+[kv]
+
+[identity]
+
diff --git a/godot/circles/addons/rivet/rivet_client.gd b/godot/circles/addons/rivet/rivet_client.gd
new file mode 100644
index 0000000..207e094
--- /dev/null
+++ b/godot/circles/addons/rivet/rivet_client.gd
@@ -0,0 +1,89 @@
+## @deprecated
+extends Node
+
+var base_url = "https://api.rivet.gg/v1"
+
+## @deprecated
+func get_token():
+ var token_env = OS.get_environment("RIVET_TOKEN")
+ assert(!token_env.is_empty(), "missing RIVET_TOKEN environment")
+ return token_env
+
+## @deprecated
+func lobby_ready(body: Variant, on_success: Callable, on_fail: Callable):
+ _rivet_request_with_body("POST", "matchmaker", "/lobbies/ready", body, on_success, on_fail)
+
+## @deprecated
+func find_lobby(body: Variant, on_success: Callable, on_fail: Callable):
+ _rivet_request_with_body("POST", "matchmaker", "/lobbies/find", body, on_success, on_fail)
+
+## @deprecated
+func player_connected(body: Variant, on_success: Callable, on_fail: Callable):
+ _rivet_request_with_body("POST", "matchmaker", "/players/connected", body, on_success, on_fail)
+
+## @deprecated
+func player_disconnected(body: Variant, on_success: Callable, on_fail: Callable):
+ _rivet_request_with_body("POST", "matchmaker", "/players/disconnected", body, on_success, on_fail)
+
+func _build_url(service, path) -> String:
+ return base_url.replace("://", "://" + service + ".") + path
+
+func _build_headers() -> PackedStringArray:
+ return [
+ "Authorization: Bearer " + get_token(),
+ ]
+
+## @deprecated
+func _rivet_request(method: String, service: String, path: String, on_success: Callable, on_fail: Callable):
+ var url = _build_url(service, path)
+ RivetHelper.rivet_print("%s %s" % [method, url])
+
+ var http_request = HTTPRequest.new()
+ add_child(http_request)
+ http_request.request_completed.connect(self._http_request_completed.bind(on_success, on_fail))
+
+ var error = http_request.request(url, _build_headers())
+ if error != OK:
+ push_error("An error occurred in the HTTP request.")
+ if on_fail != null:
+ on_fail.call("Request failed to send: %s" % error)
+
+## @deprecated
+func _rivet_request_with_body(method: String, service: String, path: String, body: Variant, on_success: Callable, on_fail: Callable):
+ var url = _build_url(service, path)
+ RivetHelper.rivet_print("%s %s: %s" % [method, url, body])
+
+ var http_request = HTTPRequest.new()
+ add_child(http_request)
+ http_request.request_completed.connect(self._http_request_completed.bind(on_success, on_fail))
+
+ var body_json = JSON.stringify(body)
+ var error = http_request.request(url, _build_headers(), HTTPClient.METHOD_POST, body_json)
+ if error != OK:
+ push_error("An error occurred in the HTTP request.")
+ if on_fail != null:
+ on_fail.call("Request failed to send: %s" % error)
+
+## @deprecated
+func _http_request_completed(result, response_code, _headers, body, on_success: Callable, on_fail: Callable):
+ if result != HTTPRequest.RESULT_SUCCESS:
+ push_error("Request error ", result)
+ if on_fail != null:
+ on_fail.call("Request error: %s" % result)
+ return
+
+ RivetHelper.rivet_print("%s: %s" % [response_code, body.get_string_from_utf8()])
+
+ if response_code != 200:
+ push_error("Request failed ", response_code, " ", body.get_string_from_utf8())
+ if on_fail != null:
+ on_fail.call("Request failed (%s): %s" % [response_code, body.get_string_from_utf8()])
+ return
+
+ var json = JSON.new()
+ json.parse(body.get_string_from_utf8())
+ var response = json.get_data()
+
+ if on_success != null:
+ on_success.call(response)
+
diff --git a/godot/circles/addons/rivet/rivet_global.gd b/godot/circles/addons/rivet/rivet_global.gd
new file mode 100644
index 0000000..cfa63c2
--- /dev/null
+++ b/godot/circles/addons/rivet/rivet_global.gd
@@ -0,0 +1,36 @@
+
+extends Node
+## Rivet [/br]
+## Mainpoint of the Rivet plugin.
+## It includes an easy access to APIs, helpers and tools. [/br]
+## @tutorial: https://rivet.gg/learn/godot
+## @experimental
+
+const _api = preload("api/rivet_api.gd")
+
+const ApiResponse = preload("api/rivet_response.gd")
+const ApiRequest = preload("api/rivet_request.gd")
+
+const _Packages = preload("api/rivet_packages.gd")
+
+var cloud_token: String
+var namespace_token: String
+var game_id: String
+var api_endpoint: String
+
+var matchmaker: _Packages.Matchmaker = _Packages.Matchmaker.new()
+
+# This variable is only accessible from editor's scripts, please do not use it in your game.
+var cli
+
+## @experimental
+func POST(path: String, body: Dictionary) -> _api.RivetRequest:
+ return _api.POST(self, path, body)
+
+## @experimental
+func GET(path: String, body: Dictionary = {}) -> _api.RivetRequest:
+ return _api.GET(self, path, body)
+
+## @experimental
+func PUT(path: String, body: Dictionary = {}) -> _api.RivetRequest:
+ return _api.PUT(self, path, body)
diff --git a/godot/circles/addons/rivet/rivet_helper.gd b/godot/circles/addons/rivet/rivet_helper.gd
new file mode 100644
index 0000000..c0b88b3
--- /dev/null
+++ b/godot/circles/addons/rivet/rivet_helper.gd
@@ -0,0 +1,115 @@
+extends Node
+
+## Triggered if running a dedicated server.
+signal start_server()
+
+## Triggered if running a client.
+signal start_client()
+
+var multiplayer_setup = false
+
+## All player tokens for players that have authenticated.
+##
+## Server only
+var player_tokens = {}
+
+## The player token for this client that will be sent on the next
+## authentication.
+##
+## Client only
+var player_token = null
+
+
+## Determines if running as a dedicated server.
+func is_dedicated_server() -> bool:
+ return OS.get_cmdline_user_args().has("--server")
+
+
+## Sets up the authentication hooks on SceneMultiplayer.
+func setup_multiplayer():
+ RivetHelper._assert(!multiplayer_setup, "RivetHelper.setup_multiplayer already called")
+ multiplayer_setup = true
+
+ var scene_multiplayer = multiplayer as SceneMultiplayer
+
+ scene_multiplayer.auth_callback = _auth_callback
+ scene_multiplayer.auth_timeout = 15.0
+
+ scene_multiplayer.peer_authenticating.connect(self._player_authenticating)
+ scene_multiplayer.peer_authentication_failed.connect(self._player_authentication_failed)
+
+ scene_multiplayer.peer_disconnected.connect(self._player_disconnected)
+
+ if is_dedicated_server():
+ rivet_print("Starting server")
+ start_server.emit()
+ else:
+ rivet_print("Starting client")
+ start_client.emit()
+
+
+## Sets the player token for the next authentication challenge.
+func set_player_token(_player_token: String):
+ RivetHelper._assert(multiplayer_setup, "RivetHelper.setup_multiplayer has not been called")
+ RivetHelper._assert(!is_dedicated_server(), "cannot called RivetHelper.set_player_token on server")
+ player_token = _player_token
+
+
+# MARK: Authentication
+func _auth_callback(id: int, buf: PackedByteArray):
+ if multiplayer.is_server():
+ # Authenticate the client if connecting to server
+
+ var json = JSON.new()
+ json.parse(buf.get_string_from_utf8())
+ var data = json.get_data()
+
+ rivet_print("Player authenticating %s: %s" % [id, data])
+ player_tokens[id] = data.player_token
+
+ var response = await Rivet.matchmaker.players.connected({
+ "player_token": data.player_token
+ })
+
+ if response.result == OK:
+ rivet_print("Player authenticated for %s" % id)
+ (multiplayer as SceneMultiplayer).complete_auth(id)
+ else:
+ rivet_print("Player authentiation failed for %s: %s" % [id, response.body])
+ (multiplayer as SceneMultiplayer).disconnect_peer(id)
+ else:
+ # Auto-approve if not a server
+ (multiplayer as SceneMultiplayer).complete_auth(id)
+
+func _player_authenticating(id):
+ rivet_print("Authenticating %s" % id)
+ var body = JSON.stringify({ "player_token": player_token })
+ (multiplayer as SceneMultiplayer).send_auth(id, body.to_utf8_buffer())
+
+
+func _player_authentication_failed(id):
+ rivet_print("Authentication failed for %s" % id)
+ multiplayer.set_multiplayer_peer(null)
+
+func _player_disconnected(id):
+ if multiplayer.is_server():
+ var player_token = player_tokens.get(id)
+ player_tokens.erase(id)
+ rivet_print("Removing player %s" % player_token)
+
+ var response = await Rivet.matchmaker.players.disconnected({
+ "player_token": player_token
+ })
+
+
+func rivet_print(message: String):
+ print("[Rivet] %s" % message)
+
+func _assert(condition: bool, message: String = "Assertion failed"):
+ if not condition:
+ # For now, we won't crash the game if an assertion fails. There are a
+ # few reasons for this. See discussion:
+ # https://app.graphite.dev/github/pr/rivet-gg/plugin-godot/33/Assert-fix#
+ # OS.crash(message)
+
+ rivet_print(message)
diff --git a/godot/circles/game.tscn b/godot/circles/game.tscn
new file mode 100644
index 0000000..dc5da0c
--- /dev/null
+++ b/godot/circles/game.tscn
@@ -0,0 +1,45 @@
+[gd_scene load_steps=5 format=3 uid="uid://djxiqhnjwr33t"]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_7f2ku"]
+size = Vector2(1206, 83)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_28ymd"]
+size = Vector2(81.5, 711.5)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_qxyaq"]
+size = Vector2(1206, 83)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_ond4j"]
+size = Vector2(68, 710.5)
+
+[node name="Game" type="Node2D"]
+
+[node name="GodotBalls" type="Node2D" parent="."]
+
+[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="."]
+_spawnable_scenes = PackedStringArray("res://godot_ball.tscn")
+spawn_path = NodePath("../GodotBalls")
+
+[node name="Wall" type="StaticBody2D" parent="."]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Wall"]
+position = Vector2(585, 698)
+shape = SubResource("RectangleShape2D_7f2ku")
+
+[node name="Wall2" type="StaticBody2D" parent="."]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Wall2"]
+position = Vector2(1201.25, 329)
+shape = SubResource("RectangleShape2D_28ymd")
+
+[node name="Wall3" type="StaticBody2D" parent="."]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Wall3"]
+position = Vector2(571, -49)
+shape = SubResource("RectangleShape2D_qxyaq")
+
+[node name="Wall4" type="StaticBody2D" parent="."]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Wall4"]
+position = Vector2(-43, 332)
+shape = SubResource("RectangleShape2D_ond4j")
diff --git a/godot/circles/gamestate.gd b/godot/circles/gamestate.gd
new file mode 100644
index 0000000..7e8985b
--- /dev/null
+++ b/godot/circles/gamestate.gd
@@ -0,0 +1,201 @@
+extends Node
+
+var godot_ball = preload("res://godot_ball.tscn")
+
+# Default game server port. Can be any number between 1024 and 49151.
+# Not on the list of registered or common ports as of November 2020:
+# https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers
+const DEFAULT_PORT = 10567
+
+# Max number of players.
+const MAX_PEERS = 12
+
+var peer = null
+
+# Name for my player.
+var player_name = "The Warrior"
+
+# Names for remote players in id:name format.
+var players = {}
+var player_tokens = {}
+
+# Signals to let lobby GUI know what's going on.
+signal player_list_changed()
+signal connection_failed()
+signal connection_succeeded()
+signal game_ended()
+signal game_error(what)
+
+
+func _ready():
+ RivetHelper.start_server.connect(start_server)
+ RivetHelper.setup_multiplayer()
+
+ multiplayer.peer_connected.connect(self._player_connected)
+ multiplayer.peer_disconnected.connect(self._player_disconnected)
+ multiplayer.connected_to_server.connect(self._connected_ok)
+ multiplayer.connection_failed.connect(self._connected_fail)
+ multiplayer.server_disconnected.connect(self._server_disconnected)
+
+
+func start_server():
+ print("Starting server on %s" % DEFAULT_PORT)
+
+ peer = ENetMultiplayerPeer.new()
+ var error = peer.create_server(DEFAULT_PORT, MAX_PEERS)
+
+ RivetHelper._assert(!error, "Could not start server")
+ multiplayer.set_multiplayer_peer(peer)
+
+ var response = await Rivet.matchmaker.lobbies.ready({})
+
+ if response.result == OK:
+ RivetHelper.rivet_print("Lobby ready")
+ else:
+ RivetHelper.rivet_print("Lobby ready failed")
+ OS.crash("Lobby ready failed")
+
+
+
+# Callback from SceneTree.
+func _player_connected(id):
+ print("Player connected %s" % id)
+
+ # Registration of a client beings here, tell the connected player that we are here.
+ if !multiplayer.is_server():
+ register_player.rpc_id(id, player_name)
+
+ # If we are the server, add the new player
+ if multiplayer.is_server():
+ var new_player = godot_ball.instantiate()
+ new_player.name = str(id)
+ get_node("/root/Game/GodotBalls").add_child(new_player)
+
+ new_player.set_position(Vector2(randf_range(100, 1000), 0))
+
+ # Update player list if no other players present
+ player_list_changed.emit()
+
+
+# Callback from SceneTree.
+func _player_disconnected(id):
+ if has_node("/root/World"): # Game is in progress.
+ if multiplayer.is_server():
+ game_error.emit("Player " + players[id] + " disconnected")
+ end_game()
+ else: # Game is not in progress.
+ # Unregister this player.
+ unregister_player(id)
+
+
+# Callback from SceneTree, only for clients (not server).
+func _connected_ok():
+ # We just connected to a server
+ connection_succeeded.emit()
+
+
+# Callback from SceneTree, only for clients (not server).
+func _server_disconnected():
+ game_error.emit("Server disconnected")
+ end_game()
+
+
+# Callback from SceneTree, only for clients (not server).
+func _connected_fail():
+ multiplayer.set_multiplayer_peer(null) # Remove peer
+ connection_failed.emit()
+
+
+# Lobby management functions.
+@rpc("any_peer", "reliable")
+func register_player(new_player_name):
+ var id = multiplayer.get_remote_sender_id()
+ players[id] = new_player_name
+ player_list_changed.emit()
+
+
+func unregister_player(id):
+ players.erase(id)
+ player_list_changed.emit()
+
+
+@rpc("call_local", "reliable")
+func load_world():
+ # Change scene.
+ var world = load("res://scenes/world.tscn").instantiate()
+ get_tree().get_root().add_child(world)
+ get_tree().get_root().get_node("Lobby").hide()
+
+ # Set up score.
+ world.get_node("Score").add_player(multiplayer.get_unique_id(), player_name)
+ for pn in players:
+ world.get_node("Score").add_player(pn, players[pn])
+ get_tree().set_pause(false) # Unpause and unleash the game!
+
+
+func join_game():
+ print("Searching for lobbies")
+ var response = await Rivet.matchmaker.lobbies.find({
+ "game_modes": ["default"]
+ })
+
+ if response.result == OK:
+ print(response.body)
+ RivetHelper.set_player_token(response.body.player.token)
+
+ var port = response.body.ports.default
+ print("Connecting to ", port.host)
+
+ peer = ENetMultiplayerPeer.new()
+ var error = peer.create_client(port.hostname, port.port)
+ RivetHelper._assert(!error, "Could not start server")
+ multiplayer.set_multiplayer_peer(peer)
+ else:
+ print("Lobby find failed: ", response)
+ game_error.emit(response.body)
+
+
+
+func get_player_list():
+ return players.values()
+
+
+func get_player_name():
+ return player_name
+
+
+# TODO: Figure out why this doesn't work as "authority"
+@rpc("any_peer")
+func begin_game():
+ if !multiplayer.is_server():
+ return
+ # Tell Rivet that this lobby is closed
+ await Rivet.matchmaker.lobbies.setClosed({})
+
+ load_world.rpc()
+ var world = get_tree().get_root().get_node("World")
+ var player_scene = load("res://scenes/player.tscn")
+
+ # Create a dictionary with peer id and respective spawn points, could be improved by randomizing.
+ var spawn_points = {}
+ var spawn_point_idx = 1
+ for p in players:
+ spawn_points[p] = spawn_point_idx
+ spawn_point_idx += 1
+
+ for p_id in spawn_points:
+ var spawn_pos = world.get_node("SpawnPoints/" + str(spawn_points[p_id])).position
+ var player = player_scene.instantiate()
+ player.synced_position = spawn_pos
+ player.name = str(p_id)
+ player.set_player_name(player_name if p_id == multiplayer.get_unique_id() else players[p_id])
+ world.get_node("Players").add_child(player)
+
+
+func end_game():
+ if has_node("/root/World"): # Game is in progress.
+ # End it
+ get_node("/root/World").queue_free()
+
+ game_ended.emit()
+ players.clear()
diff --git a/godot/circles/godot_ball.gd b/godot/circles/godot_ball.gd
new file mode 100644
index 0000000..0177a2f
--- /dev/null
+++ b/godot/circles/godot_ball.gd
@@ -0,0 +1,47 @@
+extends RigidBody2D
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ if str(name).is_valid_int():
+ print("setting authority for player ", str(name))
+ get_node("MultiplayerSynchronizer").set_multiplayer_authority(str(name).to_int())
+
+
+func _physics_process(delta):
+ pass
+ # if is_multiplayer_authority():
+ # # The server updates the position that will be notified to the clients.
+ # synced_position = position
+ # # And increase the bomb cooldown spawning one if the client wants to.
+ # last_bomb_time += delta
+ # if not stunned and is_multiplayer_authority() and inputs.bombing and last_bomb_time >= BOMB_RATE:
+ # last_bomb_time = 0.0
+ # get_node("../../BombSpawner").spawn([position, str(name).to_int()])
+ # else:
+ # # The client simply updates the position to the last known one.
+ # position = synced_position
+
+ # if not stunned:
+ # # Everybody runs physics. I.e. clients tries to predict where they will be during the next frame.
+ # velocity = inputs.motion * MOTION_SPEED
+ # move_and_slide()
+
+ # # Also update the animation based on the last known player input state
+ # var new_anim = "standing"
+
+ # if inputs.motion.y < 0:
+ # new_anim = "walk_up"
+ # elif inputs.motion.y > 0:
+ # new_anim = "walk_down"
+ # elif inputs.motion.x < 0:
+ # new_anim = "walk_left"
+ # elif inputs.motion.x > 0:
+ # new_anim = "walk_right"
+
+ # if stunned:
+ # new_anim = "stunned"
+
+ # if new_anim != current_anim:
+ # current_anim = new_anim
+ # get_node("anim").play(current_anim)
\ No newline at end of file
diff --git a/godot/circles/godot_ball.tscn b/godot/circles/godot_ball.tscn
new file mode 100644
index 0000000..73e52fc
--- /dev/null
+++ b/godot/circles/godot_ball.tscn
@@ -0,0 +1,20 @@
+[gd_scene load_steps=4 format=3 uid="uid://cxrpwf6tg2gsq"]
+
+[ext_resource type="Texture2D" uid="uid://bagf0ahxiqqfk" path="res://icon.svg" id="1_1wl3h"]
+[ext_resource type="Script" path="res://godot_ball.gd" id="1_3fv58"]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_05och"]
+size = Vector2(65, 65)
+
+[node name="GodotBall" type="CharacterBody2D"]
+script = ExtResource("1_3fv58")
+
+[node name="Sprite2D" type="Sprite2D" parent="."]
+scale = Vector2(0.5, 0.5)
+texture = ExtResource("1_1wl3h")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+position = Vector2(0.5, 1)
+shape = SubResource("RectangleShape2D_05och")
+
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
diff --git a/godot/circles/icon.svg b/godot/circles/icon.svg
new file mode 100644
index 0000000..b370ceb
--- /dev/null
+++ b/godot/circles/icon.svg
@@ -0,0 +1 @@
+
diff --git a/godot/circles/icon.svg.import b/godot/circles/icon.svg.import
new file mode 100644
index 0000000..bf49e1c
--- /dev/null
+++ b/godot/circles/icon.svg.import
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9078c90f0a54585e5e4a64fb82883def9363db985e28e385d44f9d315976d137
+size 843
diff --git a/godot/circles/loading.gd b/godot/circles/loading.gd
new file mode 100644
index 0000000..f4bc5ae
--- /dev/null
+++ b/godot/circles/loading.gd
@@ -0,0 +1,19 @@
+extends Control
+
+var game = preload("res://game.tscn").instantiate()
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ print("Loading game with ", multiplayer.is_server())
+ if !RivetHelper.is_dedicated_server():
+ print("Loading game2")
+ Gamestate.join_game()
+ print("Loading game3")
+
+ get_tree().root.add_child.call_deferred(game)
+ get_node("/root/Loading").queue_free()
+
+
+# Called every frame. 'delta' is the elapsed time since the previous frame.
+func _process(delta):
+ pass
diff --git a/godot/circles/loading.tscn b/godot/circles/loading.tscn
new file mode 100644
index 0000000..f54f3bf
--- /dev/null
+++ b/godot/circles/loading.tscn
@@ -0,0 +1,28 @@
+[gd_scene load_steps=2 format=3 uid="uid://cfpr5hufdmtv0"]
+
+[ext_resource type="Script" path="res://loading.gd" id="1_dk6u4"]
+
+[node name="Loading" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_dk6u4")
+
+[node name="Label" type="Label" parent="."]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -37.5
+offset_top = -11.5
+offset_right = 37.5
+offset_bottom = 11.5
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 4
+text = "Loading..."
diff --git a/godot/circles/project.godot b/godot/circles/project.godot
new file mode 100644
index 0000000..fa77571
--- /dev/null
+++ b/godot/circles/project.godot
@@ -0,0 +1,32 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Circles"
+run/main_scene="res://loading.tscn"
+config/features=PackedStringArray("4.2", "GL Compatibility")
+config/icon="res://icon.svg"
+
+[autoload]
+
+RivetClient="*res://addons/rivet/rivet_client.gd"
+RivetHelper="*res://addons/rivet/rivet_helper.gd"
+Rivet="*res://addons/rivet/rivet_global.gd"
+Gamestate="*res://gamestate.gd"
+
+[editor_plugins]
+
+enabled=PackedStringArray("res://addons/rivet/plugin.cfg")
+
+[rendering]
+
+renderer/rendering_method="gl_compatibility"
+renderer/rendering_method.mobile="gl_compatibility"
diff --git a/godot/circles/rivet.yaml b/godot/circles/rivet.yaml
new file mode 100644
index 0000000..3440b48
--- /dev/null
+++ b/godot/circles/rivet.yaml
@@ -0,0 +1,13 @@
+engine:
+ godot:
+
+matchmaker:
+ max_players: 12
+ docker:
+ dockerfile: 'Dockerfile'
+ ports:
+ default:
+ port: 10567
+ protocol: 'udp'
+ game_modes:
+ default: {}