Browse Source

feat: initial webrtc setup

Bryan Lee 1 year ago
parent
commit
6c8b9cb5da

+ 7 - 0
project/assets/grids/Dark/texture_01.tres

@@ -0,0 +1,7 @@
+[gd_resource type="StandardMaterial3D" load_steps=2 format=3 uid="uid://7k6o3sk5f7jg"]
+
+[ext_resource type="Texture2D" uid="uid://bdw2j5kejiegc" path="res://assets/grids/Dark/texture_01.png" id="1_lrts2"]
+
+[resource]
+albedo_texture = ExtResource("1_lrts2")
+uv1_triplanar = true

+ 7 - 0
project/assets/grids/Green/texture_01.tres

@@ -0,0 +1,7 @@
+[gd_resource type="StandardMaterial3D" load_steps=2 format=3 uid="uid://cw6w2fsecaa35"]
+
+[ext_resource type="Texture2D" uid="uid://dko2pc0uq6pwf" path="res://assets/grids/Green/texture_01.png" id="1_kago4"]
+
+[resource]
+albedo_texture = ExtResource("1_kago4")
+uv1_triplanar = true

+ 7 - 0
project/assets/grids/Light/texture_01.tres

@@ -0,0 +1,7 @@
+[gd_resource type="StandardMaterial3D" load_steps=2 format=3 uid="uid://bvag264gd85op"]
+
+[ext_resource type="Texture2D" uid="uid://d2pabwwu1pndq" path="res://assets/grids/Light/texture_01.png" id="1_32nl0"]
+
+[resource]
+albedo_texture = ExtResource("1_32nl0")
+uv1_triplanar = true

+ 7 - 0
project/assets/grids/Orange/texture_02.tres

@@ -0,0 +1,7 @@
+[gd_resource type="ORMMaterial3D" load_steps=2 format=3 uid="uid://cjsbjufs8a6h7"]
+
+[ext_resource type="Texture2D" uid="uid://ct5dmokcsmu7y" path="res://assets/grids/Orange/texture_02.png" id="1_e21av"]
+
+[resource]
+albedo_texture = ExtResource("1_e21av")
+uv1_triplanar = true

+ 1 - 0
project/icon.svg

@@ -0,0 +1 @@
+<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="124" height="124" rx="14" fill="#363d52" stroke="#212532" stroke-width="4"/><g transform="scale(.101) translate(122 122)"><g fill="#fff"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 813 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H447l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c3 34 55 34 58 0v-86c-3-34-55-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></g></svg>

+ 37 - 0
project/icon.svg.import

@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dyldw57hs5sc6"
+path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.svg"
+dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false

+ 415 - 0
project/multiplayer/game_client.gd

@@ -0,0 +1,415 @@
+extends Node
+class_name GameClient
+
+enum MessageType {
+	WEBRTC_OFFER,
+	WEBRTC_ANSWER,
+	WEBRTC_CANDIDATE,
+	WEBRTC_READY,
+}
+
+const _DEFAULT_TIMEOUT := 5.0
+var env_timeout := OS.get_environment("CLIENT_TIMEOUT")
+var timeout := float(env_timeout) if env_timeout else _DEFAULT_TIMEOUT
+
+
+func _enter_tree() -> void:
+	Program.client = self
+
+
+var server_socket := WebSocketMultiplayerPeer.new()
+var rtc_network := WebRTCMultiplayerPeer.new()
+
+
+func _ready() -> void:
+	multiplayer.peer_connected.connect(_handle_webrtc_peer_connected)
+	multiplayer.peer_disconnected.connect(_handle_webrtc_peer_connected)
+
+	connect_to_game_server("127.0.0.1", 8910)
+	await connected_to_game_server
+	await create_webrtc_mesh().settled
+	await get_tree().create_timer(5).timeout
+	ping_network.rpc()
+
+
+func _exit_tree() -> void:
+	multiplayer.peer_connected.disconnect(_handle_webrtc_peer_connected)
+	multiplayer.peer_disconnected.disconnect(_handle_webrtc_peer_connected)
+
+
+func _handle_webrtc_peer_connected(new_peer_id: int) -> void:
+	print("client(", peer_id, "): connected peer: ", new_peer_id)
+
+
+func _handle_webrtc_peer_disconnected(disconnected_peer_id: int) -> void:
+	print("client(", peer_id, "): disconnected peer: ", disconnected_peer_id)
+
+
+var peer_id := 0
+
+
+#region Client-Server Communication
+func _process(_delta: float) -> void:
+	server_socket.poll()
+	_read_incoming_packets()
+
+
+func _read_incoming_packets() -> void:
+	if server_socket.get_available_packet_count() == 0:
+		return
+	var packet = server_socket.get_packet()
+	if packet == null:
+		return
+	var data_string = packet.get_string_from_utf8()
+	var data: Dictionary = JSON.parse_string(data_string)
+	if data.has("result"):
+		_handle_server_response(data)
+	else:
+		_handle_server_message(data)
+
+
+"Record<String, { resolve(data): void, reject(err): void }>"
+var _message_response_handlers_for_id := {}
+func _handle_server_response(message: Variant) -> void:
+	"""
+	@param message: ServerResponse
+	"""
+	if not message.has("id"):
+		print("client(", peer_id, "): received response without message id")
+		return
+	if not _message_response_handlers_for_id.has(message.id):
+		print(
+			"client(", peer_id,
+			"): received response with invalid message id: ",
+			message.id,
+		)
+		return
+	var resolve_reject = _message_response_handlers_for_id[message.id]
+	var result := Result.from_dict(message.result)
+	if result.is_ok():
+		resolve_reject.resolve.call(result.unwrap())
+	else:
+		resolve_reject.reject.call(result.unwrap_err())
+	_message_response_handlers_for_id.erase(message.id)
+
+
+func _handle_server_message(message: Variant) -> void:
+	"""
+	@param message: ServerMessage
+	"""
+	if message.mtype == GameServer.MessageType.CONNECTED_TO_GAME_SERVER:
+		var result: Result = _handle_connected_to_server(message.data)
+		_respond_to_server(message, result)
+	elif message.mtype == GameServer.MessageType.WEBRTC_OFFER:
+		var result: Result = _handle_webrtc_offer(message.data)
+		_respond_to_server(message, result)
+	elif message.mtype == GameServer.MessageType.WEBRTC_ANSWER:
+		var result: Result = _handle_webrtc_answer(message.data)
+		_respond_to_server(message, result)
+	elif message.mtype == GameServer.MessageType.WEBRTC_CANDIDATE:
+		var result: Result = _handle_ice_candidate(message.data)
+		_respond_to_server(message, result)
+	elif message.mtype == GameServer.MessageType.WEBRTC_ADD_PEER:
+		var result: Result = await _handle_webrtc_add_peer(message.data)
+		_respond_to_server(message, result)
+
+"""
+type ClientMessage = {
+	id: String;
+	peer_id: String;
+	mtype: MessageType;
+	data: Variant;
+}
+"""
+func message_server(mtype: MessageType, data: Variant) -> Promise:
+	return Promise.new(
+		func(resolve, reject):
+			var message_id := str(randi())
+			_send_data_to_server({
+				"id": message_id,
+				"peer_id": peer_id,
+				"mtype": mtype,
+				"data": data,
+			})
+			_message_response_handlers_for_id[message_id] = {
+				"resolve": resolve,
+				"reject": reject,
+			}
+			await get_tree().create_timer(timeout).timeout
+			reject.call(
+				"client(" + str(peer_id) \
+				+ "): timeout on message(" + message_id + ")"
+			)
+			_message_response_handlers_for_id.erase(message_id)
+	)
+
+
+"""
+type ClientResponse = {
+	id: String;
+	peer_id: String;
+	result: Result;
+}
+"""
+func _respond_to_server(message: Variant, result: Result) -> void:
+	"""
+	@param message: ServerMessage
+	"""
+	_send_data_to_server({
+		"peer_id": peer_id,
+		"id": message.id,
+		"result": result.to_dict(),
+	})
+
+
+func _send_data_to_server(data: Variant) -> void:
+	var data_bytes := JSON.stringify(data).to_utf8_buffer()
+	server_socket.put_packet(data_bytes)
+#endregion
+
+
+#region WebRTC Signalling
+const WEBRTC_CONFIG = {
+	"iceServers": [
+		{
+			# This STUN server is provided by Google free-of-charge for testing.
+			"urls": ["stun:stun.l.google.com:19302"],
+		},
+	],
+}
+
+
+func _handle_webrtc_add_peer(data: Variant) -> Result:
+	"""
+	@param WebRTCAddPeerPayload
+	"""
+	if data.target_id == peer_id:
+		return Result.Ok(null)
+	
+	var rtc_connection := WebRTCPeerConnection.new()
+	var initialize_result := Result.from_gderr(
+		rtc_connection.initialize(WEBRTC_CONFIG)
+	)
+	if initialize_result.is_err():
+		print("client(", peer_id, "): failed to initialize RTC connection to peer: ", data.target_id)
+		return initialize_result
+	
+	print("client(", peer_id, "): adding peer: ", data.target_id)
+
+	var handle_session_description := Promise.new(
+		func(resolve, reject):
+			var description = await rtc_connection.session_description_created
+			var type: String = description[0]
+			var desc_data: Variant = description[1]
+			var res := await set_local_description(type, desc_data, data.target_id)
+			if res.is_err():
+				reject.call(res.unwrap_err())
+			else:
+				resolve.call(res.unwrap())
+	)
+
+	var handle_ice_candidate := Promise.new(
+		func(resolve, reject):
+			var candidate = await rtc_connection.ice_candidate_created
+			var media: String = candidate[0]
+			var index: int = candidate[1]
+			var sdp_name: String = candidate[2]
+			var res := await send_ice_candidate(media, index, sdp_name, data.target_id)
+			if res.is_err():
+				reject.call(res.unwrap_err())
+			else:
+				resolve.call(res.unwrap())
+	)
+
+	rtc_network.add_peer(rtc_connection, data.target_id)
+
+	if not data.to_offer:
+		return Result.Ok(null)
+	
+	# If `create_offer` succeeds, the `session_description_created` and
+	# `ice_candidate_created` signals are emitted.
+	var offer_result := Result.from_gderr(rtc_connection.create_offer())
+	if offer_result.is_err():
+		print("client(", peer_id, "): ", offer_result.to_string())
+		return offer_result
+	
+	var connection_ready_result: Result = await Promise.all([
+		handle_session_description,
+		handle_ice_candidate,
+	]).settled
+	if connection_ready_result.is_err():
+		print("client(", peer_id, "): ", connection_ready_result.to_string())
+		return connection_ready_result
+
+	print("client(", peer_id, "): added peer: ", data.target_id)
+	return Result.Ok(null)
+
+
+func _handle_webrtc_offer(data: Variant) -> Result:
+	"""
+	@param data: WebRTCOfferPayload
+	"""
+	print("client(", peer_id, "): received an offer from: ", data.sender_id)
+	if not rtc_network.has_peer(data.sender_id):
+		var err := Result.Err("failed to find offering peer: " + str(data.sender_id))
+		print("client(", peer_id, "): ", err.to_string())
+		return err
+	return Result.from_gderr(
+		rtc_network.get_peer(data.sender_id).connection.set_remote_description(
+			"offer",
+			data.offer
+		)
+	)
+
+
+func _handle_webrtc_answer(data: Variant) -> Result:
+	print("client(", peer_id, "): received an answer from: ", data.sender_id)
+	if not rtc_network.has_peer(data.sender_id):
+		var err := Result.Err("failed to find answering peer: " + str(data.sender_id))
+		print("client(", peer_id, "): ", err.to_string())
+		return err
+	return Result.from_gderr(
+		rtc_network.get_peer(data.sender_id).connection.set_remote_description(
+			"answer",
+			data.answer
+		)
+	)
+
+
+func _handle_ice_candidate(data: Variant) -> Result:
+	print("client(", peer_id, "): received an ICE candidate from: ", data.sender_id)
+	if not rtc_network.has_peer(data.sender_id):
+		var err := Result.Err("failed to find ICE candidate peer: " + str(data.sender_id))
+		print("client(", peer_id, "): ", err.to_string())
+		return err
+	return Result.from_gderr(
+		rtc_network.get_peer(data.sender_id).connection.add_ice_candidate(
+			data.media,
+			data.index,
+			data.sdp,
+		)
+	)
+
+
+"""
+type WebRTCOfferPayload = {
+	offer: String;
+	sender_id: int;
+	target_id: int;
+}
+"""
+func send_webrtc_offer(offer: Variant, target_id: int) -> Result:
+	print("client(", peer_id, "): sending offer to target: ", target_id)
+	var result: Result = await message_server(MessageType.WEBRTC_OFFER, {
+		"offer": offer,
+		"sender_id": peer_id,
+		"target_id": target_id,
+	}).settled
+	if result.is_err():
+		print("client(", peer_id, "): failed to send offer to target: ", target_id)
+	else:
+		print("client(", peer_id, "): successfully sent offer to target: ", target_id)
+	return result
+
+
+"""
+type WebRTCAnswerPayload = {
+	answer: String;
+	sender_id: int;
+	target_id: int;
+}
+"""
+func send_webrtc_answer(answer: Variant, target_id: int) -> Result:
+	print("client(", peer_id, "): sending answer to target: ", target_id)
+	var result: Result = await message_server(MessageType.WEBRTC_ANSWER, {
+		"answer": answer,
+		"sender_id": peer_id,
+		"target_id": target_id,
+	}).settled
+	if result.is_err():
+		print("client(", peer_id, "): failed to send answer to target: ", target_id)
+	else:
+		print("client(", peer_id, "): successfully sent answer to target: ", target_id)
+	return result
+
+
+func set_local_description(type: String, data: Variant, target_id: int) -> Result:
+	# Make sure a connection has been established for the offer target.
+	if not rtc_network.has_peer(target_id):
+		var err := Result.Err("Failed to find peer " + str(target_id) + " to set local description for")
+		print("client(", peer_id, "): ", err.to_string())
+		return err
+	
+	print("client(", peer_id, "): setting local description for ", target_id)
+	var set_desc_result := Result.from_gderr(
+		rtc_network.get_peer(target_id).connection.set_local_description(type, data)
+	)
+	if set_desc_result.is_err():
+		print("client(", peer_id, "): ", set_desc_result.to_string())
+		return set_desc_result
+	
+	if type == "offer":
+		return await send_webrtc_offer(data, target_id)
+	else:
+		return await send_webrtc_answer(data, target_id)
+
+
+"""
+type ICECandidatePayload = {
+	media: String;
+	index: int;
+	sdp: String;
+	sender_id: int;
+	target_id: int;
+}
+"""
+func send_ice_candidate(
+	media: String,
+	index: int,
+	sdp: String,
+	target_id: int
+) -> Result:
+	var result: Result = await message_server(MessageType.WEBRTC_CANDIDATE, {
+		"media": media,
+		"index": index,
+		"sdp": sdp,
+		"sender_id": peer_id,
+		"target_id": target_id,
+	}).settled
+	if result.is_err():
+		print("client(", peer_id, "): failed to send ICE candidate to target: ", target_id)
+	else:
+		print("client(", peer_id, "): successfully sent ICE candidate to target: ", target_id)
+	return result
+#endregion
+
+
+signal connected_to_game_server(assigned_id: int)
+func connect_to_game_server(host: String, port: int) -> void:
+	var protocol := "wss://" if Program.ssl_enabled else "ws://"
+	var address := protocol + host + ":" + str(port)
+	print("client(...): connecting to game server at: ", address)
+	server_socket.create_client(address)
+
+
+func _handle_connected_to_server(assigned_id: int) -> Result:
+	peer_id = assigned_id
+	print("client(", peer_id, "): connected to game server")
+	connected_to_game_server.emit(assigned_id)
+	return Result.Ok(assigned_id)
+
+
+func create_webrtc_mesh() -> Promise:
+	var create_mesh_result := Result.from_gderr(rtc_network.create_mesh(peer_id))
+	if create_mesh_result.is_err():
+		print("client(", peer_id, "): failed to create RTC mesh")
+		return Promise.new(func (_resolve, reject): reject.call("failed to create RTC mesh"))
+	print("client(", peer_id, "): created RTC mesh")
+	multiplayer.multiplayer_peer = rtc_network
+	
+	return message_server(MessageType.WEBRTC_READY, null)
+
+
+@rpc("any_peer")
+func ping_network() -> void:
+	print("client(", peer_id, "): ping from ", multiplayer.get_remote_sender_id())

+ 252 - 0
project/multiplayer/game_server.gd

@@ -0,0 +1,252 @@
+extends Node
+class_name GameServer
+
+enum MessageType {
+	CONNECTED_TO_GAME_SERVER,
+	WEBRTC_OFFER,
+	WEBRTC_ANSWER,
+	WEBRTC_CANDIDATE,
+	WEBRTC_ADD_PEER,
+}
+
+const _DEFAULT_PORT := 8910
+var env_port := OS.get_environment("PORT")
+var port := int(env_port) if env_port else _DEFAULT_PORT
+
+var env_id := OS.get_environment("SERVER_ID")
+var id := int(env_id) if env_id else randi()
+
+const _DEFAULT_TIMEOUT := 5.0
+var env_timeout := OS.get_environment("SERVER_TIMEOUT")
+var timeout := float(env_timeout) if env_timeout else _DEFAULT_TIMEOUT
+
+
+func _enter_tree() -> void:
+	# For prototyping, we usually want one client to simultaneously run
+	# the server and client.
+	if not (Program.is_dedicated_server or Program.is_debug):
+		queue_free()
+		return
+	Program.server = self
+
+
+var socket := WebSocketMultiplayerPeer.new()
+"Record<String, {
+	id: String;
+	rtc_ready: bool;
+}>"
+var clients := {}
+
+
+func _ready() -> void:
+	socket.peer_connected.connect(_handle_peer_connected)
+	socket.peer_disconnected.connect(_handle_peer_disconnected)
+	var result := start()
+	if result.is_err():
+		if Program.is_dedicated_server:
+			OS.kill(OS.get_process_id())
+		elif Program.is_debug:
+			queue_free()
+
+
+func _exit_tree() -> void:
+	socket.peer_connected.disconnect(_handle_peer_connected)
+	socket.peer_disconnected.disconnect(_handle_peer_disconnected)
+
+
+func _handle_peer_connected(peer_id: int) -> void:
+	print("server(", id, "): peer connected: ", peer_id)
+	clients[str(peer_id)] = {
+		"id": peer_id,
+		"rtc_ready": false,
+	}
+	await confirm_peer_connection(peer_id).settled
+
+
+func _handle_peer_disconnected(peer_id: int) -> void:
+	print("server(", id, "): socket disconnected: ", peer_id)
+
+
+#region Client-Server Communication
+func _process(_delta: float) -> void:
+	socket.poll()
+	_read_incoming_packets()
+
+
+func _read_incoming_packets() -> void:
+	if socket.get_available_packet_count() == 0:
+		return
+	var packet = socket.get_packet()
+	if packet == null:
+		return
+	var data_string = packet.get_string_from_utf8()
+	var data: Dictionary = JSON.parse_string(data_string)
+	if data.has("result"):
+		_handle_client_response(data)
+	else:
+		_handle_client_message(data)
+
+
+"Record<String, { resolve(data): void, reject(err): void }>"
+var _message_response_handlers_for_id := {}
+func _handle_client_response(message: Variant) -> void:
+	"""
+	@param message: ClientResponse
+	"""
+	if not message.has("id"):
+		print("server(", id, "): received response without message id")
+		return
+	if not _message_response_handlers_for_id.has(message.id):
+		print(
+			"server(", id,
+			"): received response from client(", message.peer_id,
+			") with invalid message id: ",
+			message.id,
+		)
+		return
+	var resolve_reject = _message_response_handlers_for_id[message.id]
+	var result := Result.from_dict(message.result)
+	if result.is_ok():
+		resolve_reject.resolve.call(result.unwrap())
+	else:
+		resolve_reject.reject.call(result.unwrap_err())
+	_message_response_handlers_for_id.erase(message.id)
+
+
+func _handle_client_message(message: Variant) -> void:
+	"""
+	@param message: ClientMessage
+	"""
+	if message.mtype == GameClient.MessageType.WEBRTC_OFFER:
+		var result: Result = await _forward_webrtc_offer(message.data.target_id, message.data).settled
+		_respond_to_peer(message, result)
+	elif message.mtype == GameClient.MessageType.WEBRTC_ANSWER:
+		var result: Result = await _forward_webrtc_answer(message.data.target_id, message.data).settled
+		_respond_to_peer(message, result)
+	elif message.mtype == GameClient.MessageType.WEBRTC_CANDIDATE:
+		var result: Result = await _forward_ice_candidate(message.data.target_id, message.data).settled
+		_respond_to_peer(message, result)
+	elif message.mtype == GameClient.MessageType.WEBRTC_READY:
+		var result: Result = await _handle_webrtc_ready(message.peer_id)
+		_respond_to_peer(message, result)
+
+
+"""
+type ServerMessage = {
+	id: String;
+	mtype: MessageType;
+	data: Variant;
+}
+"""
+func message_peer(peer_id: int, mtype: MessageType, data: Variant) -> Promise:
+	return Promise.new(
+		func(resolve, reject):
+			var message_id := str(randi())
+			_send_data_to_peer(peer_id, {
+				"id": message_id,
+				"mtype": mtype,
+				"data": data,
+			})
+			_message_response_handlers_for_id[message_id] = {
+				"resolve": resolve,
+				"reject": reject,
+			}
+			await get_tree().create_timer(timeout).timeout
+			reject.call(
+				"server(" + str(id) \
+				+ "): timeout on message(" + message_id \
+				+ ") for peer(" + str(peer_id) + ")"
+			)
+			_message_response_handlers_for_id.erase(message_id)
+	)
+
+
+"""
+type ServerResponse = {
+	id: String;
+	result: Result;
+}
+"""
+func _respond_to_peer(message: Variant, result: Result) -> void:
+	"""
+	@param message: ClientMessage
+	"""
+	_send_data_to_peer(message.peer_id, {
+		"id": message.id,
+		"result": result.to_dict(),
+	})
+
+
+func _send_data_to_peer(peer_id: int, data: Variant) -> void:
+	var data_bytes := JSON.stringify(data).to_utf8_buffer()
+	socket.get_peer(peer_id).put_packet(data_bytes)
+#endregion
+
+
+#region WebRTC Signalling
+"""
+type WebRTCAddPeerPayload = {
+	target_id: int;
+	to_offer: bool;
+}
+"""
+func _handle_webrtc_ready(from_peer_id: int) -> Result:
+	var other_ids := clients.values() \
+		.filter(func (c): return c.rtc_ready) \
+		.map(func (c): return c.id)
+	print("server(", id, "): client(", from_peer_id, ") being added to RTC peers: ", other_ids)
+	clients[str(from_peer_id)].rtc_ready = true
+
+	var add_self_to_others: Array[Promise] = []
+	for other_peer_id in other_ids:
+		var to_peer_id := int(other_peer_id)
+		add_self_to_others.append(message_peer(to_peer_id, MessageType.WEBRTC_ADD_PEER, {
+			"target_id": from_peer_id,
+			"to_offer": false,
+		}))
+	await Promise.all(add_self_to_others).settled
+
+	var add_others_to_self: Array[Promise] = []
+	for other_peer_id in other_ids:
+		var to_peer_id := int(other_peer_id)
+		add_others_to_self.append(message_peer(from_peer_id, MessageType.WEBRTC_ADD_PEER, {
+			"target_id": to_peer_id,
+			"to_offer": true,
+		}))
+	return await Promise.all(add_others_to_self).settled
+
+
+func _forward_webrtc_offer(target_id: int, data: Variant) -> Promise:
+	"""
+	@param data: WebRTCOfferPayload
+	"""
+	return message_peer(target_id, MessageType.WEBRTC_OFFER, data)
+
+
+func _forward_webrtc_answer(target_id: int, data: Variant) -> Promise:
+	"""
+	@param data: WebRTCAnswerPayload
+	"""
+	return message_peer(target_id, MessageType.WEBRTC_ANSWER, data)
+
+
+func _forward_ice_candidate(target_id: int, data: Variant) -> Promise:
+	"""
+	@param data: ICECandidatePayload
+	"""
+	return message_peer(target_id, MessageType.WEBRTC_CANDIDATE, data)
+#endregion
+
+
+func confirm_peer_connection(peer_id: int) -> Promise:
+	return message_peer(peer_id, MessageType.CONNECTED_TO_GAME_SERVER, peer_id)
+
+
+func start() -> Result:
+	print("server(", id, "): starting server on: ", port)
+	var result := Result.from_gderr(socket.create_server(port))
+	if result.is_err():
+		print("server(", id, "): failed to start server due to: ", result.to_string())
+	else:
+		print("server(", id, "): started server on: ", port)
+	return result

+ 7 - 0
project/multiplayer/id_provider.gd

@@ -0,0 +1,7 @@
+extends Node
+class_name IdentityProvider
+
+var multiplayer_id := 1
+var is_remote_player :
+	get: return multiplayer.get_unique_id() != multiplayer_id
+

+ 32 - 0
project/player/player.tscn

@@ -0,0 +1,32 @@
+[gd_scene load_steps=4 format=3 uid="uid://bv8kgs0ovjuyk"]
+
+[ext_resource type="Script" path="res://multiplayer/id_provider.gd" id="1_pnolb"]
+[ext_resource type="Script" path="res://player/player_input.gd" id="2_o0iwe"]
+
+[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_kwsv2"]
+
+[node name="Player" type="CharacterBody3D"]
+
+[node name="IdentityProvider" type="Node" parent="."]
+script = ExtResource("1_pnolb")
+
+[node name="Input" type="Node" parent="." node_paths=PackedStringArray("id_provider")]
+script = ExtResource("2_o0iwe")
+id_provider = NodePath("../IdentityProvider")
+
+[node name="Model" type="Node3D" parent="."]
+
+[node name="CSGBox3D" type="CSGBox3D" parent="Model"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.08165e-12, 1, 2.08165e-12)
+size = Vector3(1, 2, 1)
+
+[node name="CSGBox3D2" type="CSGBox3D" parent="Model"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.08165e-12, 1.5, -0.5)
+size = Vector3(1.5, 0.2, 0.5)
+
+[node name="Collision" type="CollisionShape3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.08165e-12, 1, 2.08165e-12)
+shape = SubResource("CapsuleShape3D_kwsv2")
+
+[node name="Camera" type="Camera3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 3, 4)

+ 27 - 0
project/player/player_input.gd

@@ -0,0 +1,27 @@
+extends Node
+class_name PlayerInput
+
+@export_group("Dependencies")
+@export var id_provider: IdentityProvider
+
+
+var is_crouching := false
+var is_running := false
+var direction := Vector2.ZERO
+
+var jumped := false
+
+
+func _ready():
+	set_process(not id_provider.is_remote_player)
+	set_process_input(not id_provider.is_remote_player)
+
+
+func _process(_delta: float):
+	# continuous input
+	is_crouching = Input.is_action_pressed("mod_crouch")
+	is_running = Input.is_action_pressed("mod_run")
+	direction = Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
+
+	# events
+	jumped = Input.is_action_just_pressed("jump")

+ 9 - 0
project/program.gd

@@ -0,0 +1,9 @@
+extends Node
+
+var is_dedicated_server := "--server" in OS.get_cmdline_args()
+var is_debug := OS.is_debug_build()
+var ssl_enabled := not OS.is_debug_build()
+var version := OS.get_environment("VERSION")
+
+var server: GameServer
+var client: GameClient

+ 53 - 0
project/project.godot

@@ -0,0 +1,53 @@
+; 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="ServerAuthoritativeMultiplayer"
+run/main_scene="res://root.tscn"
+config/features=PackedStringArray("4.2", "GL Compatibility")
+config/icon="res://icon.svg"
+
+[autoload]
+
+Program="*res://program.gd"
+
+[input]
+
+move_forward={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"echo":false,"script":null)
+]
+}
+move_backward={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"echo":false,"script":null)
+]
+}
+move_right={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"echo":false,"script":null)
+]
+}
+move_left={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"echo":false,"script":null)
+]
+}
+jump={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"echo":false,"script":null)
+]
+}
+
+[rendering]
+
+renderer/rendering_method="gl_compatibility"
+renderer/rendering_method.mobile="gl_compatibility"

+ 12 - 0
project/root.tscn

@@ -0,0 +1,12 @@
+[gd_scene load_steps=3 format=3 uid="uid://dqoqll4di5jv8"]
+
+[ext_resource type="Script" path="res://multiplayer/game_server.gd" id="1_npw5s"]
+[ext_resource type="Script" path="res://multiplayer/game_client.gd" id="1_u4j86"]
+
+[node name="Root" type="Node3D"]
+
+[node name="Server" type="Node" parent="."]
+script = ExtResource("1_npw5s")
+
+[node name="Client" type="Node" parent="."]
+script = ExtResource("1_u4j86")

+ 11 - 0
project/utils/object_serializer.gd

@@ -0,0 +1,11 @@
+class_name ObjectSerializer
+
+static func to_dict(object: Object) -> Dictionary:
+	var dict = {}
+	for prop in object.get_property_list():
+		if prop.name == "RefCounted" \
+			or prop.name == "script" \
+			or prop.name == "Built-in script":
+			continue
+		dict[prop.name] = to_dict(object[prop.name]) if object[prop.name] is Object else object[prop.name]
+	return dict

+ 21 - 0
project/utils/optional/LICENSE.md

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Tienne_k
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 148 - 0
project/utils/optional/README.md

@@ -0,0 +1,148 @@
+# godot-optional
+## Better error handling for Godot!
+Introduces to Godot Option, Result, and custom Error types inspired by Rust
+
+## Option
+A generic `Option<T>`
+
+Options are types that explicitly annotate that a value can be `null`, and forces the user to handle the exception
+
+Basic usage:
+```gdscript
+# By returning an Option, it's clear that this function can return null, which must be handled
+func get_player_stats(id: String) -> Option:
+	return Option.None() # Represents a null
+	return Option.Some( data ) # Sucess!
+
+var res: Option = get_player_stats("player_3")
+if res.is_none():
+	print("Player doesn't exist!")
+	return
+
+var data = res.expect("Already checked if None or Some above") # Safest
+var data = res.unwrap() # Crashes if res is None. Least safe, but quick for prototyping
+var data = res.unwrap_or( 42 ) # Get from default value
+var data = res.unwrap_or_else( some_complex_function ) # Get default value from function
+var data = res.unwrap_unchecked() # It's okay to use it here because we've already checked above
+```
+
+Option also comes with a safe way to index arrays and dictionaries
+```gdscript
+var my_arr = [2, 4, 6]
+print( Option.arr_get(1))  # Prints "4"
+print( Option.arr_get(4))  # Prints "None" because index 4 is out of bounds
+```
+![](screenshots/example_attack.png)
+
+
+## Result
+A generic `Result<T, E>`
+
+Results are types that explicitly annotate that an operation (most often a function call) can fail, and forces the user to handle the exception
+
+In case of a success, the `Ok` variant is returned containing the value returned by said operation
+In case of a failure, the `Err` variant is returned containing information about the error.
+
+Basic usage:
+```gdscript
+# By returning a Result, it's clear that this function can fail
+func my_function() -> Result:
+	return Result.from_gderr(ERR_PRINTER_ON_FIRE)
+	# Also supports custom error types!
+	return Result.Err( Error.new(Error.MyCustomError).info("expected", "some_value") )
+	return Result.Err("my error message")
+	return Result.Ok(data) # Success!
+
+var res: Result = my_function()
+# Ways to handle results:
+if res.is_err():
+	res.stringify_error() # @GlobalScope.Error to String
+	# Custom errors can bear extra details. See the "Custom error types" section below
+	res.err_cause(...) .err_info(...) .err_msg(...)\
+		.report()
+	return
+
+var data = res.expect("Already checked if Err or Ok above") # Safest
+var data = res.unwrap() # Crashes if res is Err. Least safe, but quick for prototyping
+var data = res.unwrap_or( 42 ) # Defaults to 42
+var data = res.unwrap_or_else( some_complex_function )
+var data = res.unwrap_unchecked() # It's okay to use it here because we've already checked above
+```
+
+Result also comes with a safe way to open files and parse JSON
+
+```gdscript
+# "Error" refers to custom error types. Not to be confused with @GlobalScope.Error
+ var res: Result = Result.open_file("res://file.txt", FileAccess.READ) # Result<FileAccess, Error>
+ var json_res: Result = Result.parse_json_file("res://data.json") # Result<data, Error>
+```
+![](screenshots/example_file.png)
+
+## Custom error types
+Godot-optional introduces a custom `Error` class for custom error types. 
+
+The aim is to allow for errors to carry with them details about the exception, leading to better error handling. 
+It also acts as a place to have a centralized list of errors specific to your application, as Godot's global Error enum doesn't cover most cases. 
+
+Usage:
+```gdscript
+# Can be made from a Godot error, and with optional additional details
+var myerr = Error.new(ERR_PRINTER_ON_FIRE) .cause('Not enough ink!')
+	# Or with an additional message too
+	.msg("The printer gods demand input..")
+
+# Prints: "Printer on fire { "cause": "Not enough ink!", "msg": "The printer gods demand input.." }"
+print(myerr)
+myerr.report()
+
+# You can even nest them!
+Error.from_gderr(ERR_TIMEOUT) .cause( Error.new(Error.Other).msg("Oh no!") )
+
+# Used alongside a Result:
+Result.Err( Error.new(Error.MyCustomError) )
+Result.open_file( ... ) .err_msg("Failed to open the specified file")
+```
+
+You can also define custom error types specific to your application in the Error script
+```gdscript
+# res://addons/optional/Error.gd
+enum {
+	Other,
+	# Define custom errors here ...
+	MyCustomError,
+}
+```
+![](screenshots/example_custom_errors.png)
+
+## Enum Structs
+Godot-optional now supports enum structs!
+
+Usage:
+
+```gdscript
+# Declare enum
+static var AnimalState: EnumStruct = EnumStruct.new()\
+	.add(&"Alive", { "is_hungry" : false })\
+	.add(&"Dead") # A dead animal can't be hungry
+
+# There are a couple ways to get an EnumStruct variant:
+var cat_state: EnumVariant = AnimalState.Alive
+cat_state.is_hungry = true
+# or
+var cat_state: EnumVariant = AnimalState.variant(&"Alive", { "is_hungry" : true })
+
+print(cat_state) # Prints: Alive { "is_hungry" : true }
+```
+Notice how `EnumStruct`s and `EnumVariant`s can both be treated like normal objects, but with the user declared properties.
+
+`Note`: There are also `EnumDict`s which use Dictionaries as variants instead of `EnumVariant`
+
+The above code is the same as doing the following in Rust:
+```rust
+enum AnimalState {
+	Alive{ is_hungry: bool },
+	Dead,
+}
+
+let cat_state: AnimalState = AnimalState::Alive{ is_hungry: true };
+```

+ 108 - 0
project/utils/optional/enum_dict.gd

@@ -0,0 +1,108 @@
+class_name EnumDict extends RefCounted
+## A class for declaring struct-like enums
+##
+## The aim is to have enums hold extra data with them.[br]
+## In most programming languages, enums are, under the hood, represented as [int]s.[br]
+## This makes them effective for simple state flags, but useless for carrying variant-specific data (similar motives to [Error])[br]
+## [br]
+## [EnumDict]s work in the [b]exact same[/b] way as [EnumStruct]s, so see those further documentation[br]
+## The only difference is that [EnumDict]s return Dictionaries as variants (with an extra [code]EnumDict[/code] property) instead of [EnumVariant]s, which could be useful in some cases[br]
+## Whether you use [EnumStruct] or [EnumDict] is up to your preference
+
+# Dictionary<StringName, Dictionary>
+# {
+#	 &'VariantA' : {},
+#	 &'VariantB' : { ... },
+#	 ...
+# }
+var _variants: Dictionary = {}
+
+
+func _init(variants: Dictionary = {}):
+	_variants = variants
+	# This call is deferred to allow for .add() calls
+	call_deferred(&"lock")
+
+func lock() -> EnumDict:
+	_variants.make_read_only()
+	return self
+
+## Adds a variant to this [EnumDict][br]
+## Usage:
+## [codeblock]
+## static var MyEnum: EnumDict = EnumDict.new()\
+##		 .add(&"VariantA")\
+##		 .add(&"VariantB", { 'value' : 0 })
+## [/codeblock]
+## Note: You can only add variants during initialization[br]
+func add(type: StringName, value: Dictionary = {}) -> EnumDict:
+	assert(!_variants.is_read_only(), "Please add variants only during initialization")
+	_variants[type] = value
+	return self
+
+## Gets a variant from this [EnumDict], with (optional) initial values
+func variant(type: StringName, init_values: Dictionary = {}) -> Dictionary:
+	assert(_variants.has(type))
+	
+	var values: Dictionary = _variants[type].duplicate(true)
+	values.EnumDict = type
+	values.merge(init_values, true)
+	return values
+
+## Gets all the variants in this [EnumDict]
+func get_variant_list() -> Array[StringName]:
+	return Array(_variants.keys(), TYPE_STRING_NAME, &"", null) # ???
+	# return _variants.keys() .map(func(key):	return StringName(key))
+
+## Checks whether [param enum_dict] is within this [EnumDict][br]
+## Returns a [Result]<[EnumDict], [Error]>:[br]
+## - [code]Err(Error(ERR_DOES_NOT_EXIST))[/code] if this [EnumDict] doesn't contain the variant[br]
+## - [code]Err(Error(ERR_INVALID_DATA))[/code] if the variant exists but there are missing variables[br]
+## - [code]Ok(enum_dict)[/code] otherwise[br]
+## See [Result], [Error]
+func contains(enum_dict: Dictionary) -> Result:
+	assert(enum_dict.has("EnumDict"), "Parameter enum_dict must be an EnumDict variant")
+	
+	if !_variants.has(enum_dict.EnumDict):
+		return Result.newError(ERR_DOES_NOT_EXIST)\
+			.err_info('variant', enum_dict.EnumDict)\
+			.err_msg("This enum does not have the specified variant")
+	elif !enum_dict.has_all( _variants[enum_dict.EnumDict].keys() ):
+		return Result.newError(ERR_INVALID_DATA)\
+			.err_info('expected', _variants[enum_dict.EnumDict].keys())\
+			.err_info('found', enum_dict.keys())\
+			.err_msg("The enum dict is missing some paramters")
+	
+	return Result.Ok(enum_dict)
+
+
+## Checks whether this [EnumDict] has the specified [param variant]
+func has(variant: StringName) -> bool:
+	return _variants.has(variant)
+
+
+func _get(property: StringName) -> Variant:
+	if _variants.has(property):
+		var values: Dictionary = _variants[property].duplicate(true)
+		values.EnumDict = property
+		return values
+	return null
+
+func _get_property_list():
+	return _variants.keys()\
+		.map(func(key: StringName):	return { 'name': key, 'type': TYPE_DICTIONARY })
+
+
+## Turns [param enum_dict] into a prettier [String]
+static func stringify(enum_dict: Dictionary) -> String:
+	if !enum_dict.has("EnumDict"):
+		push_error("Parameter enum_dict must be an EnumDict variant")
+		return str(enum_dict)
+	
+	if enum_dict.size() == 1: # If there's only EnumDict (no other values)
+		return String(enum_dict.EnumDict)
+	
+	var values: Dictionary = enum_dict.duplicate()
+	values.erase("EnumDict")
+	return '%s %s' % [ enum_dict.EnumDict, values ]
+

+ 138 - 0
project/utils/optional/enum_struct.gd

@@ -0,0 +1,138 @@
+class_name EnumStruct extends RefCounted
+## A class for declaring struct-like enums
+##
+## The aim is to have enums hold extra data with them.[br]
+## In most programming languages, enums are, under the hood, represented as [int]s.[br]
+## This makes them effective for simple state flags, but useless for carrying variant-specific data (similar motives to [Error])[br]
+## See also [EnumDict], [EnumVariant][br]
+## [br]
+## There are a couple ways to declare [EnumStruct]s:
+## [codeblock]
+## # Let's declare an EnumStruct where VariantA will behave like a normal enum,
+## # and VariantB will have a 'value' field (defaulting to 0)
+## 
+## # Using a Dictionary of StringNames
+## static var MyEnum: EnumStruct = EnumStruct.new({
+##		 &"VariantA" : {},
+##		 &"VariantB" : { "value" : 0 }
+## })
+## 
+## # Using chained methods
+## static var MyEnum: EnumStruct = EnumStruct.new()\
+##		 .add(&"VariantA")\
+##		 .add(&"VariantB", { 'value' : 0 })
+## [/codeblock]
+## Note: Once an [EnumStruct] is initialized, you cannot change the variants of it.[br]
+## [br][br]
+## Usage:
+## [codeblock]
+## # Declare enum
+## static var AnimalState: EnumStruct = EnumStruct.new()\
+##		 .add(&"Alive", { "is_hungry" : false })\
+##		 .add(&"Dead") # A dead animal can't be hungry
+## 
+## # There are a couple ways to get an EnumStruct variant:
+## var cat_state: EnumVariant = AnimalState.Alive
+## cat_state.is_hungry = true
+## # or
+## var cat_state: EnumVariant = AnimalState.variant(&"Alive", { "is_hungry" : true })
+## 
+## print(cat_state) # Prints: Alive { "is_hungry" : true }
+## [/codeblock]
+## [br]
+## The above code is the same as doing the following in Rust:
+## [codeblock]
+## enum AnimalState {
+##		 Alive{ is_hungry: bool },
+##		 Dead,
+## }
+## 
+## let cat_state: AnimalState = AnimalState::Alive{ is_hungry: true }
+## [/codeblock]
+
+
+# Dictionary<StringName, Dictionary>
+# {
+#	 &'VariantA' : {},
+#	 &'VariantB' : { ... },
+#	 ...
+# }
+var _variants: Dictionary = {}
+
+
+func _init(variants: Dictionary = {}):
+	_variants = variants
+	# This call is deferred to allow for .add() calls
+	call_deferred(&"lock")
+
+func lock() -> EnumStruct:
+	_variants.make_read_only()
+	return self
+
+## Adds a variant to this [EnumStruct][br]
+## Usage:
+## [codeblock]
+## static var MyEnum: EnumStruct = EnumStruct.new()\
+##		 .add(&"VariantA")\
+##		 .add(&"VariantB", { 'value' : 0 })
+## [/codeblock]
+## Note: You can only add variants during initialization[br]
+func add(type: StringName, value: Dictionary = {}) -> EnumStruct:
+	assert(!_variants.is_read_only(), "Please add variants only during initialization")
+	_variants[type] = value
+	return self
+
+## Gets a variant from this [EnumStruct], with (optional) initial values
+func variant(type: StringName, init_values: Dictionary = {}) -> EnumVariant:
+	assert(_variants.has(type))
+	
+	var values: Dictionary = _variants[type].duplicate(true)
+	values.merge(init_values, true)
+	return EnumVariant.new(self, type, values)
+
+## Gets all the variants in this [EnumStruct]
+func get_variant_list() -> Array[StringName]:
+	return Array(_variants.keys(), TYPE_STRING_NAME, &"", null) # ???
+
+## Checks whether [param enum_dict] is within this [EnumStruct][br]
+## Returns a [Result]<[EnumStruct], [Error]>:[br]
+## - [code]Err(Error(ERR_INVALID_PARAMETER))[/code] if the [param enum_variant] is from a different [EnumStruct][br]
+## - [code]Err(Error(ERR_DOES_NOT_EXIST))[/code] if this [EnumStruct] doesn't contain the [param enum_variant][br]
+## - [code]Err(Error(ERR_INVALID_DATA))[/code] if the [param enum_variant] exists but there are missing variables[br]
+## - [code]Ok(enum_dict)[/code] otherwise[br]
+## See [Result], [Error]
+func contains(enum_variant: EnumVariant) -> Result:
+	if enum_variant.base_enum != self:
+		return Result.newError(ERR_INVALID_PARAMETER)\
+			.err_info('variant', enum_variant.variant)\
+			.err_msg("The variant is from a different enum")
+	
+	elif !_variants.has(enum_variant.variant):
+		return Result.newError(ERR_DOES_NOT_EXIST)\
+			.err_info('variant', enum_variant.variant)\
+			.err_msg("This enum does not have the specified variant")
+	
+	elif !enum_variant.values.has_all( _variants[enum_variant.variant].keys() ):
+		return Result.newError(ERR_INVALID_DATA)\
+			.err_info('expected', _variants[enum_variant.variant].keys())\
+			.err_info('found', enum_variant.values.keys())\
+			.err_msg("The enum dict is missing some paramters")
+	
+	return Result.Ok(enum_variant)
+
+
+## Checks whether this [EnumStruct] has the specified [param variant]
+func has(variant: StringName) -> bool:
+	return _variants.has(variant)
+
+
+func _get(property: StringName) -> Variant:
+	if _variants.has(property):
+		var values: Dictionary = _variants[property].duplicate(true)
+		return EnumVariant.new(self, property, values)
+	return null
+
+func _get_property_list():
+	return _variants.keys()\
+		.map(func(key: StringName):	return { 'name': key, 'type': TYPE_DICTIONARY })
+

+ 70 - 0
project/utils/optional/enum_variant.gd

@@ -0,0 +1,70 @@
+class_name EnumVariant extends RefCounted
+## A [EnumStruct] variant
+## 
+## [EnumVariant]s represent the variants of an [EnumStruct].[br]
+## They differ from normal enums in that they can hold extra data with them
+## See also [EnumStruct][br]
+## Usage:
+## [codeblock]
+## # Declare enum
+## static var AnimalState: EnumStruct = EnumStruct.new()\
+##		 .add(&"Alive", { "is_hungry" : false })\
+##		 .add(&"Dead") # A dead animal can't be hungry
+## 
+## # There are a couple ways to get an EnumStruct variant:
+## var cat_state: EnumVariant = AnimalState.Alive
+## cat_state.is_hungry = true
+## # or
+## var cat_state: EnumVariant = AnimalState.variant(&"Alive", { "is_hungry" : true })
+## 
+## print(cat_state) # Prints: Alive { "is_hungry" : true }
+## [/codeblock]
+## Note: Although the properties of an [EnumVariant] are stored as a Dictionary, you are meant to set and get them like normal
+## [codeblock]
+## # You could do this:
+## my_enum_variant.values[property] = something
+## 
+## # But this is the intended way:
+## my_enum_variant.property = something
+## # Or alternatively:
+## my_enum_variant.set_value(property, something)
+## [/codeblock]
+
+## The [EnumStruct] this variant is from
+var base_enum: EnumStruct # TODO remove?
+var variant: StringName
+var values: Dictionary = {}
+
+func _init(base: EnumStruct, variant_: StringName, values_: Dictionary):
+	base_enum = base
+	variant = variant_
+	values = values_
+
+
+## Set a property of this [EnumVariant] and returns Self[br]
+## To initialize values in bulk, you can use the [method EnumStruct.variant] method
+func set_value(property: StringName, value: Variant) -> EnumVariant:
+	assert(values.has(property))
+	values[property] = value
+	return self
+
+
+func _set(property: StringName, value: Variant):
+	if values.has(property):
+		values[property] = value
+		return true
+	return false
+
+func _get(property: StringName) -> Variant:
+	# var varname: String = String(property)
+	return values.get(property)
+
+func _get_property_list():
+	return values.keys()\
+		.map(func(key):	return { 'name': key, 'type': typeof(values[key]) })
+
+
+func _to_string() -> String:
+	if values.is_empty():
+		return String(variant)
+	return '%s %s' % [ variant, values ]

+ 115 - 0
project/utils/optional/error.gd

@@ -0,0 +1,115 @@
+class_name Error extends RefCounted
+## A class for user-defined error types
+## 
+## The aim is to allow for errors to carry with them details about the exception, leading to better error handling[br]
+## It also acts as a place to have a centralized list of errors specific to your application, as [enum @GlobalScope.Error] doesn't cover most cases[br]
+## 
+## Usage:
+## [codeblock]
+## # Can be made from a Godot error, and with optional additional details
+## var myerr = Error.new(ERR_PRINTER_ON_FIRE) .cause('Not enough ink!')
+##		 # Or with an additional message too
+##		 .msg("The printer gods demand input..")
+## 
+## # Prints: "Printer on fire { "cause": "Not enough ink!", "msg": "The printer gods demand input.." }"
+## print(myerr)
+## 
+## # You can even nest them!
+## Error.from_gderr(ERR_TIMEOUT) .cause( Error.new(Error.Other).msg("Oh no!") )
+## 
+## # Used alongside a Result:
+## Result.Err( Error.new(Error.MyCustomError) )
+## Result.open_file( ... ) .err_msg("Failed to open the specified file.")
+## [/codeblock]
+## [br]
+## You can also define custom error types in the [Error] script
+## [codeblock]
+## # res://addons/optional/Error.gd
+## enum {
+##		 Other,
+##		 # Define custom errors here ...
+##		 MyCustomError,
+## }
+## [/codeblock]
+
+# Custom error types
+# You can define yours here
+# This enum is unnamed for convenience
+enum {
+	Other = ERR_PRINTER_ON_FIRE+1, ## Other error type. Below ERR_PRINTER_ON_FIRE is reserved for [enum @GlobalScope.Error]
+	# Define custom errors here ...
+	ExampleError, ## Error used in the examples. See res://addons/optional/examples/
+}
+
+## The [Error]'s type. This can be a custom error type defined in the [Error] script or a [enum @GlobalScope.Error]
+var type: int = Other
+## Optional additional details about this error
+var details: Dictionary = {}
+## Additional message to show when converting to string or printing
+var message: String = ''
+
+## Create a new [Error] of type [param t], with (optional) additional [param _details][br]
+func _init(t: int, _details: Dictionary = {}):
+	type = t
+	details = _details
+
+## Set the message to show when converting to string or printing
+func msg(message_: String) -> Error:
+	message = message_
+	return self
+
+## Shorthand for[br]
+## [code]Error.new(some_type, { 'cause' : [/code][param cause][code] })[/code][br]
+## Returns self
+func cause(cause: Variant) -> Error:
+	details.cause = cause
+	return self
+
+## Puts [code]self[/code] as the cause of a new [code]Error([/code][param t][code])[/code] [br]
+## This is similar to doing
+## [codeblock]
+## Error.new(t) .cause(error)
+## [/codeblock]
+func as_cause(t: int) -> Error:
+	return Error.new(t).cause(self)
+
+## Puts [code]self[/code] as the cause of [code]Error([/code][param t][code])[/code] by modifying [code]self[/code][br]
+## The difference between this and [method as_cause] is [method as_cause_mut] modifies [code]self[/code] instead of 
+## creating a new one and potentially having 2 [Error]s floating around in your code
+func as_cause_mut(t: int) -> Error:
+	var inner: Error = Error.new(type, details)
+	inner.message = message
+	
+	type = t
+	details = { 'cause' : inner }
+	message = ''
+	return self
+
+## Adds additional info to this error. Shorthand for[br]
+## [code]Error.new(some_type, { [/code][param key][code] : [/code][param value][code] })[/code][br]
+## Returns self
+func info(key: String, value: Variant) -> Error:
+	details[key] = value
+	return self
+
+## Returns whether this error is an [enum @GlobalScope.Error]
+func is_gderror() -> bool:
+	return type <= ERR_PRINTER_ON_FIRE
+
+## Pushes this error to the built-in debugger and OS terminal
+func report() -> void:
+	push_error(str(self))
+
+# Aa my eyes
+func _to_string() -> String:
+	# Details dictionary
+	var infostr: String = '' if details.is_empty() else ' ' + str(details)
+	var msgstr: String = '' if message.is_empty() else message + ': '
+	
+	# Godot error
+	if type <= ERR_PRINTER_ON_FIRE:
+		return msgstr + error_string(type) + infostr
+	# Custom error
+	var s = get_script().get_script_constant_map() .find_key(type)
+	return msgstr + (s if s != null else '(Invalid error type: %s)' % type) + infostr
+

+ 322 - 0
project/utils/optional/option.gd

@@ -0,0 +1,322 @@
+class_name Option extends RefCounted
+## A generic [code]Option<T>[/code]
+## 
+## Options are types that explicitly annotate that a value can be [code]null[/code], and forces the user to handle the exception[br]
+## Basic usage: [br]
+## [codeblock]
+## # By returning an Option, it's clear that this function can return null, which must be handled
+## func get_player_stats(id: String) -> Option:
+##		 return Option.None() # Represents a null
+##		 return Option.Some( data ) # Sucess!
+## # ...
+## var res: Option = get_player_stats("player_3")
+## if res.is_none():
+##		 print("Player doesn't exist!")
+##		 return
+## var data = res.expect("Already checked if None or Some above") # Safest
+## var data = res.unwrap() # Crashes if res is None. Least safe, but quick for prototyping
+## var data = res.unwrap_or( 42 ) # Get from default value
+## var data = res.unwrap_or_else( some_complex_function ) # Get default value from function
+## var data = res.unwrap_unchecked() # It's okay to use it here because we've already checked above
+## [/codeblock][br]
+## [Option] also comes with a safe way to index arrays and dictionaries[br]
+## [codeblock]
+## var my_arr = [2, 4, 6]
+## print( Option.arr_get(1))	# Prints "4"
+## print( Option.arr_get(4))	# Prints "None" because index 4 is out of bounds
+## [/codeblock]
+
+var _value: Variant = null
+
+# TODO signal value_changed
+
+static func Some(v) -> Option:
+	assert(v != null, "Cannot assign null to an Some")
+	return Option.new(v)
+
+static func None() -> Option:
+	return Option.new(null)
+
+func _to_string() -> String:
+	if _value == null:
+		return 'None'
+	return 'Some(%s)' % _value
+
+## Creates a duplicate with the inner value duplicated as well
+func duplicate() -> Option:
+	if _value == null:
+		return Option.new(null)
+	return Option.new( _value.duplicate() )
+
+func _init(v):
+	_value = v
+
+## Returns [code]true[/code] if the option is a [code]Some[/code] value
+func is_some() -> bool:
+	return _value != null
+
+## Returns [code]true[/code] if the option is a [code]None[/code] value
+func is_none() -> bool:
+	return _value == null
+
+## Returns the contained [code]Some[/code] value[br]
+## Stops the program if the value is a [code]None[/code] with a custom panic message provided by [code]msg[/code][br]
+## Example:
+## [codeblock]
+## var will_not_fail: String = Option.Some("value")\
+##		 .expect("Shouldn't fail because (...) ")
+## print(will_not_fail) # Prints "value"
+## 
+## var will_fail = Option.None()\
+##		 .expect("This fails!")
+## [/codeblock]
+func expect(msg: String) -> Variant:
+	assert(_value != null, msg)
+	return _value
+
+## Returns the contained [code]Some[/code] value[br]
+## Stops the program if the value is a [code]None[/code][br]
+## The use of this method is generally discouraged because it may panic. 
+## Instead, prefer to handle the [code]None[/code] case explicitly, or call [method unwrap_or], [method unwrap_or_else]
+## Example: [codeblock]
+## var will_not_fail: String = Option.Some("air") .unwrap()
+## print(will_not_fail) # Prints "air"
+## 
+## var will_fail = Option.None() .unwrap() # Fails
+## [/codeblock]
+func unwrap() -> Variant:
+	if _value == null:
+		push_warning("Unresolved unwrap(). Please handle options in release builds")
+		OS.alert("Called Option::unwrap() on a None value", 'Option unwrap error')
+		OS.kill(OS.get_process_id())
+		return
+	return _value
+
+## Returns the contained [code]Some[/code] value or a provided default[br]
+## Example: [codeblock]
+## print( Option.Some("car") .unwrap_or("bike") ) # Prints "car"
+## print( Option.None() .unwrap_or("bike") ) # Prints "bike"
+## [/codeblock]
+func unwrap_or(default) -> Variant:
+	if _value == null:
+		assert(default != null)
+		return default
+	return _value
+
+## [code]f: func() -> T[/code][br]
+## Returns the contained [code]Some[/code] value or computes it from a closure
+## Example: [codeblock]
+## var k: int = 10
+## print( Option.Some(4) .unwrap_or_else(func():		return 2 * k) ) # Prints 4
+## print( Option.None() .unwrap_or_else(func():		return 2 * k) ) # Prints 20
+## [/codeblock][br]
+## This is different from [method unwrap_or] in that the value is lazily evaluated, so it's good for methods that may take a long time to compute
+func unwrap_or_else(f: Callable) -> Variant:
+	if _value == null:
+		return f.call()
+	return _value
+
+## Similar to [method unwrap] where the contained value is returned[br]
+## The difference is that there are NO checks to see if the value is null because you are assumed to have already checked if it's a None with [method is_none] or [method is_some][br]
+## If used incorrectly, it can lead to unpredictable behavior
+func unwrap_unchecked() -> Variant:
+	return _value
+
+## [code]f: func(T) -> U[/code][br]
+## Maps an [code]Option<T>[/code] to [code]Option<U>[/code] by applying a function to the contained value (if [code]Some[/code]) or returns [code]None[/code] (if [code]None[/code])
+func map(f: Callable) -> Option:
+	if _value == null:
+		return self
+	return Option.new( f.call(_value) )
+
+## [code]f: func(T) -> void[/code][br]
+## Maps an [code]Option<T>[/code] to [code]Option<U>[/code] by applying a function to the contained value mutably (if [code]Some[/code])
+func map_mut(f: Callable) -> Option:
+	if _value == null:
+		return self
+	f.call(_value)
+	return self
+
+## [code]default: U[/code][br]
+## [code]f: func(T) -> U[/code][br]
+## Returns the provided default result (if none), or applies a function to the contained value (if any)
+## Example: [codeblock]
+## var x = Option.Some("foo")
+## print( x.map_or(42, func(v):		return v.length()) ) # Prints 3
+## 
+## var x = Option.None()
+## print( x.map_or(42, func(v):		return v.length()) ) # Prints 42
+## [/codeblock]
+func map_or(default, f: Callable) -> Variant:
+	if _value == null:
+		assert(default != null)
+		return default
+	return f.call(_value)
+
+## [code]default: func() -> U[/code][br]
+## [code]f: func(T) -> U[/code][br]
+## Computes a default function result (if none), or applies a different function to the contained value (if any)[br]
+## Same as [method map_or] but computes the default from a function
+func map_or_else(default: Callable, f: Callable) -> Variant:
+	if _value == null:
+		return default.call()
+	return f.call(_value)
+
+## This is the rust equivalent of [code]Option.and()[/code][br]
+## [param optb]: [code]Option<U>[/code][br]
+## Returns None if the option is None, otherwise returns [param optb]
+## Example:
+## [codeblock]
+## print( Option.Some(2) .and_opt(Option.None()) )			# Prints: None
+## print( Option.None() .and_opt(Option.Some("foo")) )	# Prints: None
+## print( Option.Some(2) .and_opt(Option.Some("foo")) ) # Prints: Some("foo")
+## print( Option.None() .and_opt(Option.None()) )			 # Prints: None()
+## [/codeblock]
+func and_opt(optb: Option) -> Option:
+	return optb if _value != null and optb._value != null else Option.None()
+
+## [code]f: func(T) -> Option<U>[/code][br]
+## Returns None if the option is None, otherwise calls [code]f[/code] with the contained value and returns the result[br]
+## Example:
+## [codeblock]
+## func square_if_small_enough(x: int) -> Option:
+##		 if x > 42:
+##				 return Option.None()
+##		 return Option.Some(x * x)
+## 
+## print( Option.Some(4) .and_then(square_if_small_enough) ) # Prints Some(16)
+## print( Option.Some(1000) .and_then(square_if_small_enough) ) # Prints None
+## print( Option.None() .and_then(square_if_small_enough) ) # Prints None
+## [/codeblock]
+func and_then(f: Callable) -> Option:
+	if _value == null:
+		return self
+	return f.call(_value)
+
+## This is the rust equivalent of [code]Option.or()[/code][br]
+## [param optb]: [code]Option<T>[/code][br]
+## Returns the option if it contains a value, ortherwise returns [param optb][br]
+## Example:
+## [codeblock]
+## print( Option.Some(2) .or_opt(Option.None()) )		# Prints: Some(2)
+## print( Option.None() .or_opt(Option.Some(100)) )	# Prints: Some(100)
+## print( Option.Some(2) .or_opt(Option.Some(100)) ) # Prints: Some(2)
+## print( Option.None() .or_opt(Option.None()) )		 # Prints: None
+## [/codeblock]
+func or_opt(optb: Option) -> Option:
+	return self if _value != null else optb
+
+## [code]f: func() -> Option<T>[/code][br]
+## Returns the option if it contains a value, otherwise calls [code]f[/code] and returns the result[br]
+## Example:
+## [codeblock]
+## func nobody() -> Option:
+##		 return Option.None()
+## 
+## func vikings() -> Option:
+##		 return Option.Some("vikings")
+## 
+## print( Option.Some("barbarians") .or_else(vikings) ) # Prints: Some("barbarians")
+## print( Option.None .or_else(vikings) ) # Prints: Some("vikings")
+## print( Option.None .or_else(nobody) ) # Prints: None
+## [/codeblock]
+func or_else(f: Callable) -> Option:
+	if _value != null:
+		return self
+	return f.call()
+
+## This is the rust equivalent of [code]Option.xor()[/code][br]
+## [param optb]: [code]Option<T>[/code][br]
+## Returns Some if exactly one of [param self], [param optb] is Some, otherwise returns None[br]
+## Example:
+## [codeblock]
+## print( Option.Some(2) .xor_opt(Option.None()) )		# Prints: Some(2)
+## print( Option.None() .xor_opt(Option.Some(100)) )	# Prints: Some(100)
+## print( Option.Some(2) .xor_opt(Option.Some(100)) ) # Prints: None
+## print( Option.None() .xor_opt(Option.None()) )		 # Prints: None
+## [/codeblock]
+func xor_opt(optb: Option) -> Option:
+	if (_value == null) == (optb._value == null):
+		return Option.None()
+	return self if _value != null else optb
+
+## Takes the value out of this option, leaving a None in its place
+## Example:
+## [codeblock]
+## var x = Option.Some(2)
+## var y = x.take()
+## print("x=", x, " y=", y) # Prints "x=None y=Some(2)"
+## [/codeblock]
+func take() -> Option:
+	var o: Option = Option.new(_value)
+	_value = null
+	return o
+
+## Replaces teh actual value in the option by the given [param value]
+## Example:
+## [codeblock]
+## var x = Option.Some(2)
+## var old = x.replace(5)
+## print("x=", x, " y=", y) # Prints "x=Some(5) y=Some(2)"
+## [/codeblock]
+func replace(value) -> Option:
+	assert(value != null)
+	var old: Option = Option.new(_value)
+	_value = value
+	return old
+
+## Converts [code]Option<Option<T>>[/code] to [code]Option<T>[/code][br]
+func flatten() -> Option:
+	if _value == null or !(_value is Option):
+		return self
+	return _value
+
+## [param predicate]: [code]func(T) -> bool[/code]
+func filter(predicate: Callable) -> Option:
+	if _value == null:
+		return self
+	if predicate.call(_value):
+		return self
+	return Option.None()
+
+## Ensures that the type of the contained value is [param type][br]
+## This is similar to doing
+## [codeblock]
+## option.filter(func(v):	return typeof(v) == type)
+## [/codeblock]
+func typed(type: Variant.Type) -> Option:
+	if typeof(_value) == type:
+		return self
+	return Option.None()
+
+## Transforms the [Option] into a [Result]
+func ok_or(err: Variant) -> Result:
+	if _value == null:
+		return Result.Err(err)
+	return Result.Ok(_value)
+
+## Same as [method ok_or] but computes the error value from the lambda [param err]
+func ok_or_else(err: Callable) -> Result:
+	if _value == null:
+		return Result.Err(err.call())
+	return Result.Ok(_value)
+
+
+# ----------------------------------------------------------------
+# ** Util **
+# ----------------------------------------------------------------
+
+## Safe version of [code]arr[idx][/code]
+static func arr_get(arr: Array, idx: int) -> Option:
+	if idx >= arr.size():
+		return Option.new(null)
+	return Option.new(arr[idx])
+
+## Safe version of [code]dict[key][/code]
+static func dict_get(dict: Dictionary, key: Variant) -> Option:
+	if !dict.has(key):
+		return Option.new(null)
+	return Option.new(dict[key])
+
+static func get_node(parent: Node, path: NodePath) -> Option:
+	return Option.new(parent.get_node_or_null(path))

+ 7 - 0
project/utils/optional/plugin.cfg

@@ -0,0 +1,7 @@
+[plugin]
+
+name="Optional"
+description="Introduces Option, Result, custom Error types, and Enum structs inspired by Rust"
+author="Tienne_k"
+version="2.0"
+script="plugin.gd"

+ 2 - 0
project/utils/optional/plugin.gd

@@ -0,0 +1,2 @@
+@tool
+extends EditorPlugin

+ 357 - 0
project/utils/optional/result.gd

@@ -0,0 +1,357 @@
+class_name Result extends RefCounted
+## A generic [code]Result<T, E>[/code]
+## 
+## Results are types that explicitly annotate that an operation (most often a function call) can fail, and forces the user to handle the exception[br]
+## In case of a success, the [code]Ok[/code] variant is returned containing the value returned by said operation.[br]
+## In case of a failure, the [code]Err[/code] variant is returned containing information about the error.
+## Basic usage:[br]
+## [codeblock]
+## # By returning a Result, it's clear that this function can fail
+## func my_function() -> Result:
+##		 return Result.from_gderr(ERR_PRINTER_ON_FIRE)
+##		 return Result.Err("my error message")
+##		 return Result.Ok(data) # Success!
+## # ...
+## var res: Result = my_function()
+## if res.is_err():
+##		 # stringify_error() is specific to this Godot addon
+##		 print(res) .stringify_error()
+##		 return
+## var data = res.expect("Already checked if Err or Ok above") # Safest
+## var data = res.unwrap() # Crashes if res is Err. Least safe, but quick for prototyping
+## var data = res.unwrap_or( 42 )
+## var data = res.unwrap_or_else( some_complex_function )
+## var data = res.unwrap_unchecked() # It's okay to use it here because we've already checked above
+## [/codeblock][br]
+## [Result] also comes with a safe way to open files
+## [codeblock]
+##	var res: Result = Result.open_file("res://file.txt", FileAccess.READ)
+##	var json_res: Result = Result.parse_json_file("res://data.json")
+## [/codeblock]
+
+var _value: Variant
+var _is_ok: bool
+
+## Contains the success value
+static func Ok(v) -> Result:
+	return Result.new(v, true)
+
+## Contains the error value
+static func Err(err) -> Result:
+	return Result.new(err, false)
+
+## Constructs a [Result] from the global [enum @GlobalScope.Error] enum[br]
+## [constant @GlobalScope.OK] will result in the Ok() variant, everything else will result in Err()
+static func from_gderr(err: int) -> Result:
+	return Result.new(err, err == OK)
+
+## Constructs an [code]Err([/code] [Error] [code])[/code] with the error code [param err][br]
+## Both [enum @GlobalScope.Error] and custom [Error] codes are allowed[br]
+## [constant @GlobalScope.OK] will result in the Ok() variant, everything else will result in Err()[br]
+## Also see [method toError]
+static func newError(err: int) -> Result:
+	if err == OK:	return Result.new(OK, true)
+	return Result.new(Error.new(err), false)
+
+func _to_string() -> String:
+	if _is_ok:
+		return 'Ok(%s)' % _value
+	return 'Err(%s)' % _value
+
+func duplicate() -> Result:
+	return Result.new(_value.duplicate(), _is_ok)
+
+func _init(v, is_ok: bool):
+	_value = v
+	_is_ok = is_ok
+
+## Returns true if the result if Ok
+func is_ok() -> bool:
+	return _is_ok
+
+## Returns true if the result if Err
+func is_err() -> bool:
+	return !_is_ok
+
+## Converts from [Result][code]<T, E>[/code] to [Option][code]<T>[/code]
+func ok() -> Option:
+	return Option.new(_value if _is_ok else null)
+
+## Converts from [Result][code]<T, E>[/code] to [Option][code]<E>[/code]
+## Basically [method ok] but with Err
+func err() -> Option:
+	return Option.new(_value if !_is_ok else null)
+
+## [code]op: func(T) -> U[/code][br]
+## Maps a [code]Result<T, E>[/code] to [code]Result<U, E>[/code] by applying a function to a contained Ok value, leaving an Err value untouched[br]
+## Example: [br]
+## [codeblock]
+## var res: Result = Result.Ok(5) .map(func(x):		return x * 2)
+## print(res) # Prints "Ok(10)"
+## 
+## var res: Result = Result.Err("Nope") .map(func(x):		return x * 2)
+## print(res) # Prints "Err(Nope)"
+## [/codeblock]
+func map(op: Callable) -> Result:
+	if _is_ok:
+		return Result.new( op.call(_value), true )
+	return self
+
+## [code]f: func(T) -> void[/code][br]
+## Maps a [code]Result<T, E>[/code] to [code]Result<U, E>[/code] by applying a function to the contained value mutably (if [code]Ok[/code])
+## Also good if you simply want to execute a block of code if [code]Ok[/code]
+func map_mut(f: Callable) -> Result:
+	if !_is_ok:	return self
+	f.call(_value)
+	return self
+
+## [code]default: U[/code][br]
+## [code]f: func(T) -> U[/code][br]
+## Returns the provided default if Err, or applies a function to the contained value if Ok.
+func map_or(default: Variant, f: Callable) -> Variant:
+	if !_is_ok:
+		return default
+	return f.call(_value)
+
+## [code]default: func(E) -> U[/code][br]
+## [code]f: func(T) -> U[/code][br]
+## Same as [method map_or] but computes the default (if Err) from a function
+func map_or_else(default: Callable, f: Callable) -> Variant:
+	if _is_ok:
+		return f.call(_value)
+	return default.call(_value)
+
+## [code]op: func(E) -> F[/code][br]
+## Maps a [code]Result<T, E>[/code] to [code]Result<T, F>[/code] by applying a function to a contained Err value, leaving an Ok value untouched[br]
+## Example: [br]
+## [codeblock]
+## var res: Result = Result.Ok(5) .map_err(func(x):		return "error code: " + str(x))
+## print(res) # Prints "Ok(5)"
+## 
+## var res: Result = Result.Err(48) .map_err(func(x):		return "error code: " + str(x))
+## print(res) # Prints "Err(error code: 48)"
+## [/codeblock]
+func map_err(op: Callable) -> Result:
+	if _is_ok:
+		return self
+	return Result.new( op.call(_value), false )
+
+## [code]f: func(E) -> void[/code][br]
+## Maps a [code]Result<T, E>[/code] to [code]Result<T, F>[/code] by applying a function to the contained error mutably (if [code]Err[/code])
+## Also good if you simply want to execute a block of code if [code]Err[/code]
+func map_err_mut(f: Callable) -> Result:
+	if _is_ok:	return self
+	f.call(_value)
+	return self
+
+## Turns a [code]Result<_, @GlobalScope.Error>[/code] into a [code]Result<_, String>[/code][br]
+## This is similar to doing the following but safer
+## [codeblock]
+## result.map_err(func(err: int):	return error_string(err))
+## [/codeblock]
+## See also [enum @GlobalScope.Error], [method @GlobalScope.error_string]
+func stringify_err() -> Result:
+	if _is_ok or typeof(_value) != TYPE_INT:	return self
+	_value = error_string(_value)
+	return self
+
+## Converts this [code]Err([/code][enum @GlobalScope.Error][code])[/code] into [code]Err([/code][Error][code])[/code][br]
+## This is similar to doing the following but safer
+## [codeblock]
+## result.map_err(Error.new)
+## [/codeblock]
+## Also see [method newError]
+func toError() -> Result:
+	if _is_ok or typeof(_value) != TYPE_INT:	return self
+	_value = Error.new(_value)
+	return self
+
+## Set the message to show when converting to string or printing if this is an [code]Err[/code]
+## This is similar to doing
+## [codeblock]
+## result.map_err(func(err: Error): return err.msg(message))
+## [/codeblock]
+## See also [method toError], [method err_cause], [method err_info], [method Error.msg]
+func err_msg(message: String) -> Result:
+	if _is_ok or !(_value is Error):	return self
+	_value.message = message # Error.msg(message) expanded
+	return self
+
+## Calls [method Error.cause] if this is an [code]Err([/code][Error][code])[/code][br]
+## This is similar to doing
+## [codeblock]
+## result.map_err(func(err: Error): return err.cause(cause))
+## [/codeblock]
+## See also [method toError], [method err_msg], [method err_info], [method Error.cause]
+func err_cause(cause: Variant) -> Result:
+	if _is_ok or !(_value is Error):	return self
+	_value.details.cause = cause # Error.cause(cause) expanded
+	return self
+
+## Calls [method Error.as_cause_mut] if this is an [code]Err([/code][Error][code])[/code][br]
+## This is similar to doing
+## [codeblock]
+## result.map_err_mut(func(err: Error):		err.as_cause_mut(type))
+## [/codeblock]
+## See also [method err_cause], [method Error.cause], [method Error.as_cause]
+func err_as_cause(err: int) -> Result:
+	if _is_ok or !(_value is Error):	return self
+	# Error::as_cause_mut() expanded
+	var inner: Error = Error.new(_value.type, _value.details)
+	inner.message = _value.message
+	
+	_value.type = err
+	_value.details = { 'cause' : inner }
+	_value.message = ''
+	return self
+
+## Calls [method Error.info] if this is an [code]Err([/code][Error][code])[/code][br]
+## This is similar to doing
+## [codeblock]
+## result.map_err(func(err: Error): return err.info(key, value))
+## [/codeblock]
+## See also [method toError], [method err_msg], [method err_cause], [method Error.info]
+func err_info(key: String, value: Variant) -> Result:
+	if _is_ok or !(_value is Error):	return self
+	_value.details[key] = value # Error.info(key, value) expanded
+	return self
+
+## Returns the contained [code]Ok[/code] value[br]
+## Stops the program if the value is an Err with a custom panic message provided by [code]msg[/code][br]
+## Example:
+## [codeblock]
+## var will_not_fail: String = Result.Ok("value")\
+##		 .expect("Shouldn't fail because (...) ")
+## print(will_not_fail) # Prints "value"
+## 
+## var will_fail = Result.Err("Oh no!")\
+##		 .expect("This fails!")
+## [/codeblock]
+func expect(msg: String) -> Variant:
+	assert(_is_ok, msg + ': ' + str(_value))
+	return _value
+
+## Same as [method expect] except stops the program if the value is an Ok
+func expect_err(msg: String) -> Variant:
+	assert(!_is_ok, msg + ': ' + str(_value))
+	return _value
+
+## Returns the contained Ok value[br]
+## Stops the program if the value is an Err[br]
+## The use of this method is generally discouraged because it may panic. 
+## Instead, prefer to handle the Err case explicitly, or call [method unwrap_or], [method unwrap_or_else]
+## Example: [codeblock]
+## var will_not_fail: String = Result.Ok("air") .unwrap()
+## print(will_not_fail) # Prints "air"
+## 
+## var will_fail = Result.Err("Oh no!") .unwrap() # Fails
+## [/codeblock]
+func unwrap() -> Variant:
+	if !_is_ok:
+		push_warning("Unresolved unwrap(). Please handle results in release builds")
+		OS.alert("Called Result::unwrap() on an Err. value:\n %s" % _value, 'Result unwrap error')
+		OS.kill(OS.get_process_id())
+		return
+	return _value
+
+## Same as [method unwrap] but panics in case of an Ok
+func unwrap_err() -> Variant:
+	if _is_ok:
+		push_warning("Unresolved unwrap_err(). Please handle results in release builds")
+		OS.alert("Called Result::unwrap_err() on an Ok. value:\n %s" % _value, 'Result unwrap error')
+		OS.kill(OS.get_process_id())
+		return
+	return _value
+
+## Returns the contained Ok value or a provided default
+func unwrap_or(default: Variant) -> Variant:
+	return _value if _is_ok else default
+
+## [code]op: func(E) -> T[/code][br]
+## Same as [method unwrap_or] but computes the default (if Err) from a function with the contained error as an argument
+## This is different from [method unwrap_or] in that the value is lazily evaluated, so it's good for methods that may take a long time to compute[br]
+## See also [method Option.unwrap_or_else]
+func unwrap_or_else(op: Callable) -> Variant:
+	if _is_ok:
+		return _value
+	return op.call(_value)
+
+## Similar to [method unwrap] where the contained value is returned[br]
+## The difference is that there are NO checks to see if the value is an Err because you are assumed to have already checked[br]
+## If used incorrectly, it will lead to unpredictable behavior
+func unwrap_unchecked() -> Variant:
+	return _value
+
+## Pushes this error to the built-in debugger and OS terminal (if this result is an Err(_))
+func report() -> Result:
+	if _is_ok:	return self
+	push_error(str(_value))
+	return self
+
+## [code]op: func(T) -> Result<U, E>[/code][br]
+## Does nothing if the result is Err. If Ok, calls [code]op[/code] with the contained value and returns the result[br]
+func and_then(op: Callable) -> Result:
+	if !_is_ok:
+		return self
+	return op.call(_value)
+
+## [code]op: func(E) -> Result<T, F>[/code][br]
+## Calls [param op] if the result is Err, otherwise returns the Ok value
+## Example: [br]
+## [codeblock]
+## func sq(x: int) -> Result:		return Result.Ok(x * x)
+## func err(x: int) -> Result:		return Result.Err(x)
+## 
+## print(Ok(2).or_else(sq).or_else(sq), Ok(2))
+## print(Ok(2).or_else(err).or_else(sq), Ok(2))
+## print(Err(3).or_else(sq).or_else(err), Ok(9))
+## print(Err(3).or_else(err).or_else(err), Err(3))
+## [/codeblock][br]
+## I totally didn't just copy and paste everything from Rust documentation haha
+func or_else(op: Callable) -> Result:
+	if _is_ok:
+		return self
+	return op.call(_value)
+
+
+# ----------------------------------------------------------------
+# ** Util **
+# ----------------------------------------------------------------
+
+## Open a file safely and return the result[br]
+## Returns [code]Result<FileAccess, Error>[/code][br]
+## See also [FileAccess], [Error]
+static func open_file(path: String, flags: FileAccess.ModeFlags) -> Result:
+	var f = FileAccess.open(path, flags)
+	if f == null:
+		return Result.Err( Error.new(FileAccess.get_open_error()) .info('path', path) )
+	return Result.Ok(f)
+
+## Open and parse the given file as JSON[br]
+## [codeblock]
+## var data = Result.parse_json_file("path_to_file.json") # Ok(data)
+## # Err(File not found { "path" : "nonexistent_file.json" })
+## var error = Result.parse_json_file("nonexistent_file.json")
+## [/codeblock]
+## See also [method open_file], [Error]
+static func parse_json_file(path: String) -> Result:
+	var json: JSON = JSON.new()
+	return Result.open_file(path, FileAccess.READ)\
+		.and_then(func(f: FileAccess):
+			# Yo why json.get_error_message() and get_error_line() always empty?
+			# Anyways, it's here just in case
+			return Result.from_gderr( json.parse(f.get_as_text()) ) .toError()\
+				.err_msg(json.get_error_message())\
+				.err_info('line', json.get_error_line())
+			)\
+		.map(func(__):	return json.data)
+
+
+func to_dict() -> Dictionary:
+	return {
+		"is_ok": _is_ok,
+		"value": ObjectSerializer.to_dict(_value) if _value is Object else _value,
+	}
+
+static func from_dict(data: Dictionary) -> Result:
+	return Result.new(data.value, data.is_ok)

+ 21 - 0
project/utils/promise/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 TheWalruzz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 131 - 0
project/utils/promise/promise.gd

@@ -0,0 +1,131 @@
+extends RefCounted
+class_name Promise
+
+
+enum Status {
+	RESOLVED,
+	REJECTED
+}
+
+
+signal settled(result: Result)
+signal resolved(value: Variant)
+signal rejected(reason: Variant)
+
+
+## Generic rejection reason
+const PROMISE_REJECTED := "Promise rejected"
+
+
+var is_settled := false
+
+
+func _init(callable: Callable):
+	resolved.connect(
+		func(value: Variant): 
+			is_settled = true
+			settled.emit(Result.Ok(value)),
+		CONNECT_ONE_SHOT
+	)
+	rejected.connect(
+		func(rejection: Variant):
+			is_settled = true
+			settled.emit(Result.Err(rejection)),
+		CONNECT_ONE_SHOT
+	)
+	
+	callable.call_deferred(
+		func(value: Variant):
+			if not is_settled:
+				resolved.emit(value),
+		func(rejection: Variant):
+			if not is_settled:
+				rejected.emit(rejection)
+	)
+
+	
+func then(resolved_callback: Callable) -> Promise:
+	resolved.connect(
+		resolved_callback, 
+		CONNECT_ONE_SHOT
+	)
+	return self
+	
+	
+func catch(rejected_callback: Callable) -> Promise:
+	rejected.connect(
+		rejected_callback, 
+		CONNECT_ONE_SHOT
+	)
+	return self
+	
+	
+static func from(input_signal: Signal) -> Promise:
+	return Promise.new(
+		func(resolve: Callable, _reject: Callable):
+			var number_of_args := input_signal.get_object().get_signal_list() \
+				.filter(func(signal_info: Dictionary) -> bool: return signal_info["name"] == input_signal.get_name()) \
+				.map(func(signal_info: Dictionary) -> int: return signal_info["args"].size()) \
+				.front() as int
+			
+			if number_of_args == 0:
+				await input_signal
+				resolve.call(null)
+			else:
+				# only one arg in signal is allowed for now
+				var result = await input_signal
+				resolve.call(result)
+	)
+
+
+static func from_many(input_signals: Array[Signal]) -> Array[Promise]:
+	return input_signals.map(
+		func(input_signal: Signal): 
+			return Promise.from(input_signal)
+	)
+
+	
+static func all(promises: Array[Promise]) -> Promise:
+	return Promise.new(
+		func(resolve: Callable, reject: Callable):
+			var resolved_promises: Array[bool] = []
+			var results := []
+			results.resize(promises.size())
+			resolved_promises.resize(promises.size())
+			resolved_promises.fill(false)
+	
+			for i in promises.size():
+				promises[i].then(
+					func(value: Variant):
+						results[i] = value
+						resolved_promises[i] = true
+						if resolved_promises.all(func(_value: bool): return _value):
+							resolve.call(results)
+				).catch(
+					func(rejection: Variant):
+						reject.call(rejection)
+				)
+	)
+	
+	
+static func any(promises: Array[Promise]) -> Promise:
+	return Promise.new(
+		func(resolve: Callable, reject: Callable):
+			var rejected_promises: Array[bool] = []
+			var rejections: Array[Variant] = []
+			rejections.resize(promises.size())
+			rejected_promises.resize(promises.size())
+			rejected_promises.fill(false)
+	
+			for i in promises.size():
+				promises[i].then(
+					func(value: Variant): 
+						resolve.call(value)
+				).catch(
+					func(rejection: Variant):
+						rejections[i] = rejection
+						rejected_promises[i] = true
+						if rejected_promises.all(func(value: bool): return value):
+							reject.call(rejections)
+				)
+	)

+ 9 - 0
project/world.tscn

@@ -0,0 +1,9 @@
+[gd_scene load_steps=2 format=3 uid="uid://dygqpiqxq8leo"]
+
+[ext_resource type="Material" uid="uid://7k6o3sk5f7jg" path="res://assets/grids/Dark/texture_01.tres" id="1_kwd6s"]
+
+[node name="World" type="Node3D"]
+
+[node name="Floor" type="CSGBox3D" parent="."]
+material_override = ExtResource("1_kwd6s")
+size = Vector3(100, 0.1, 100)