Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support returning values from rpc_id() calls or add something like rpc_id_with_return() #2032

Open
tx350z opened this issue Dec 27, 2020 · 25 comments

Comments

@tx350z
Copy link

tx350z commented Dec 27, 2020

Describe the project you are working on

MMO game with scalable dedicated-server framework.

Describe the problem or limitation you are having in your project

Godot's RPC networking is great for small peer-to-peer applications. There are a few limitations that make games needing dedicated servers complicated. I have many cases where I need to make a request to a server and have a result returned. The most obvious example is user authentication shown in the code snippets below.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Currently rpc calls do not return values and I understand the reasoning. However, since rpc_id() is a call to a specific peer, there should be no case where multiple peers return values from that single call. An alternative is to have a separate "rpc_id_with_return" function or something similar.

In my application, there are multiple servers, each responsible for a "functional domain" such as user authentication, lobby, arena management, and multiple arenas ("arena" is the game play environment). The servers may be spread across multiple physical hosts and are managed by a "server_manager" server (one instance on each physical host). As the user moves through login, to lobby, to game play, the client disconnects from one server and connects to another server. The specific instance of each server the client connects to is assigned by the server_manager. For security reasons, the client never connects directly to the server manager. Only the functional domain servers communicate with the server_manager.

A very stripped down version of what I'm doing now is shown below. The client sends a user authentication request to the authentication server. If the authentication is successful, the authentication server sends a request to the server_manager for a lobby_server assignment. The authentication results along with the lobby_server assignment are then returned to the client.
DISCLAIMER: I haven't tried running this code since it is just a fraction of my actual client and server logic.

Current Client Code:

func on_ok_button_pressed()->void:
	rpc_id(1, "authenticate_user", login_field.text, password_field.text, "authenticate_user_callback");

remote func authenticate_user_callback(_auth_return:Dictionary)->void:
	if _auth_return["success"]:
		# close connection to authenticate server
		get_tree().get_network_peer().close();
		get_tree().set_network_peer(null);
		
		# open conection to assigned lobby server
		var lobby_server = NetworkedMultiplayerENet.new();
		lobby_server.create_client(_auth_return["svr_addr"], _auth_return["svr_port"]);
		get_tree().set_network_peer(lobby_server);
		
		# change scene to lobby.tscn
		get_tree().change_scene("res://lobby/lobby.tscn");
	else:
		msg_label.set_text(_auth_return["err_msg"]);

Current Server Code:

var request_cache:Dictionary;
remote func authenticate_user(_login:String, _password:String, _callback_method:String)->void:
	var auth_return:Dictionary;
	auth_return["success"] = is_login_valid(_login, _password);
	
	if !auth_return["success"]:
		auth_return["err_msg"] = "Invalid login or password.";
		rpc_id(get_tree().get_rpc_sender_id(), _callback_method, auth_return);
		return;
	else:
		# cache the request into so it can be returned to the user
		auth_return["err_msg"] = "";
		var request_id = randi();
		request_cache[request_id] = {
			"client_peer_id": get_tree().get_rpc_sender_id(),
			"callback_method": _callback_method,
			"auth_return": auth_return
		}
		
		# get_lobby_server() in the server_manager returns the IP & port of the assigned lobby server
		# the result is returned via an rpc_id call from the server_manager
		rpc_id(server_manager_id, "get_lobby_server", "get_lobby_server_callback", request_id);

remote func get_lobby_server_callback(_request_id, _server_assignment:Dictionary)->void:
	# get request info from cache
	var request:Dictionary = request_cache[_request_id];
	request_cache.erase(_request_id);
	var auth_return:Dictionary = request[_request_id]["auth_return"];
	auth_return["lobby_server"] = _server_assignment;
	
	rpc_id(request["client_peer_id"], request["callback_method"], auth_return);

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

This is what I would like to have.

Client Code:

func on_ok_button_pressed()->void:
	var auth_return:Dictionary = rpc_id(1, "authenticate_user", login_field.text, password_field.text);
	if auth_return["success"]:
		# close connection to authenticate server
		get_tree().get_network_peer().close();
		get_tree().set_network_peer(null);
		
		# open conection to assigned lobby server
		var lobby_server = NetworkedMultiplayerENet.new();
		lobby_server.create_client(auth_return["svr_addr"], auth_return["svr_port"]);
		get_tree().set_network_peer(lobby_server);
		
		# change scene to lobby.tscn
		get_tree().change_scene("res://lobby/lobby.tscn");
	else:
		msg_label.set_text(auth_return["err_msg"]);

Server Code:

remote func authenticate_user(_login:String, _password:String)->Dictionary:
	var auth_return:Dictionary;
	auth_return["success"] = is_login_valid(_login, _password);
	
	if !auth_return["success"]:
		auth_return["err_msg"] = "Invalid login or password.";
	else:
		# get_lobby_server in the server_manager returns the IP & port of the assigned lobby server
		auth_return["lobby_server"] = rpc_id(server_manager_id, "get_lobby_server");
		auth_return["err_msg"] = "";
	
	return auth_return;

If this enhancement will not be used often, can it be worked around with a few lines of script?

There is a work around as shown above. However, it is far more than a few lines of code. And note that because the rpc_id call and the callback are decoupled, additional logic must be added to detect the failure to receive a response to the requests.

Is there a reason why this should be core and not an add-on in the asset library?

I don't believe this can be an add-on since the RPC networking is tightly integrated into the engine.

@Calinou Calinou changed the title Support returning values from rpc_id() calls or add something like rpc_id_with_return() Support returning values from rpc_id() calls or add something like rpc_id_with_return() Dec 27, 2020
@jonbonazza
Copy link

I have experience building multiplayer games with dedicated servers and i find the premise of this proposal flawed.

There are actually very few instances in multiplayer games where having the server resoond directly to a clientbrequest makes sense--mostly due to the need to limit bandwidth and data usage.

In fact, your example of authentication is also flawed. Authentication should be implemented using RPCs at all. A proper architecture would contain a separate lightweight auth server that worked over secure http using dome well estavlished orotocal to generate some auth token that would be used in subsequent requests for per request authN and authZ.

I'm not totally agaibst the idea of supporting returning data from rpcs but it significantly complicates the implementation because rpcs are asynchronous in nature. If we were to implement sometging like this we'd need a really good use case or reason to do so and I can't think if a single one off the top of my head (not to say they don't exist)

@jonbonazza
Copy link

jonbonazza commented Dec 28, 2020

Maybe @Faless has some other thoughts here?

@tx350z
Copy link
Author

tx350z commented Dec 28, 2020

@jonbonazza As stated in the OP, the code examples are the simplest possible to illustrate the need. They aren't intended to show best practice, just illustrate a use case where the ability for an RPC call to return values will greatly simplify server-side and client-side code.

You are correct that using HTTPS for request/response is an option for authentication and maybe other functions. However, that requires adding a web-server to the server-side tech stack and the complexity of interfacing HTTPS requests with Godot coded servers.

You point concerning bandwidth usage is valid if the rpc-with-return calls are used improperly. If available, they should only be used when the client cannot change state until a response from the server is received. That is why I used player authentication as an example. Additionally, HTTPS is one of the least performant protocols in existence due its bloated syntax. Using it for all request/response use cases will create far more bandwidth/data usage than an RPC call which serializes data.

In my mind, without some type of simple request/response support in RPC, it will always be limited to use in small peer-to-peer games running on the same sub-net.

@bfelbo
Copy link

bfelbo commented Dec 28, 2020

I think there's a actually a decent number of instances where this would be useful. A few examples in the context of MMOs like World of Warcraft:

  • Entering dungeons
  • Joining a group/party/guild of other players
  • Buying/selling/repairing/upgrading/looting items
  • Initiating/completing quests

These are all examples, where the change to the client state should wait on server confirmation as latency isn't critical and as it would be very confusing to do a rollback of it afterwards.

Allowing RPC calls to return values is simpler and more intuitive (see code examples by @tx350z). As the codebase grows, it's really nice for the developer to keep the logic in a single function instead of having to split part of it into a separate callback function. All developers understand that functions can return values, but many newcomers haven't used callbacks much and understandably get a bit confused by the concept.

If the RPC API changes to a single keyword/function as proposed in #1939 (comment), then we wouldn't need to introduce rpc_id_with_return() and could just make the generic rpc keyword return values with a default void return. As such, this proposal would add very limited complexity to the API.

@dsnopek
Copy link

dsnopek commented Dec 28, 2020

The example code given here couldn't work exactly as written, without the game execution grinding to a halt when the RPC call is made, and without handling the possibility that the RPC call would fail or never return.

Instead, you'd need to do something like:

func on_ok_button_pressed()->void:
	var result: RPCResult = yield(rpc_id_with_return(1, "authenticate_user", login_field.text, password_field.text), 'completed')
	if result.succeeded:
		var auth_return: Dictionary = result.return_val
		if auth_return["success"]:
			# the rest of the code...

Note the addition of the yield() so we pause execution of this function and allow the game to continue running. I also made up an RPCResult object that allows checking if the RPC succeeded, and then grabbing the return value. (On the implementation side, Godot will probably need to keep track of all these RPC's in progress, and after a timeout resume those co-routines with a failed result if the RPC never returns.)

So, while it's maybe a bit simpler for the developer, it's doesn't really get that simple.

I also share @jonbonazza's feeling that this won't really be useful very often. In the networked games I've made with Godot, I've never needed to have an RPC (at least with Godot's High-level Multiplayer API) return a value.

In my games, I usually have two parts to the server side: (1) a part that handles authentication, matchmaking, user profiles, etc (for which I use Nakama) and (2) an instance of Godot that handles sync'ing of the real-time game via Godot's High-level Multiplayer API. The clients communicate with Nakama over HTTPS, and those calls have return values (but not direct ones, of course, because communicating over HTTP also needs to use signals or co-routines to not pause execution of the game, like described above) but the actual real-time game synchronization doesn't need to have return values.

In my personal opinion, in the uncommon case of needing to have RPC's with return values, it can be implemented the way the OP is currently doing it.

@dsnopek
Copy link

dsnopek commented Dec 28, 2020

I think there's a actually a decent number of instances where this would be useful. A few examples in the context of MMOs like World of Warcraft:

* Entering dungeons

* Joining a group/party/guild of other players

* Buying/selling/repairing/upgrading/looting items

* Initiating/completing quests

I actually don't think an RPC returning a value would work in almost any of these cases.

Keep in mind that an RPC returning a value means calling a single method and returning a value by the end of the method. If the method needs to do anything asynchronously to achieve its result (ie. where it'd need to yield control to the engine, in order to draw something on the screen or send/receive other network requests, etc), then it won't be done by the end of the method, and can't just a return a value, because it doesn't have the result yet or isn't finished yet.

I'll walk through an imaginary implementation of a couple of these examples below...

Entering a dungeon

  1. A player initiates entering a dungeon for their party, so their client sends an RPC to the server requesting to enter it
  2. The server then loads up the necessary data about the dungeon and sends it to all members of the party via an RPC. This can't be an immediate return value for two reasons: the 2nd RPC needs to go to all party members, and loading the data about the dungeon may need to be done asynchronously to allow the server to continue responding to other clients while it's loading the necessary data.
  3. The clients for each party member start loading up the data for that dungeon. They can't immediately respond with a return value, because loading up the data will probably need to happen asynchronously, in order to allow the client to keep working and show some kind of loading screen.
  4. As each client finishes loading the data for the dungeon it tells the server via RPC that it's ready
  5. Once all clients for each member of the party have told the server they are ready, it sends an RPC to them saying it's time start, and all players are now playing in the dungeon

I don't see anywhere in there that an RPC could have returned a value, because all the operations initiated by the RPC on both the client and server needed to be done asynchronously to allow the client or server to keep running after the RPC call was made.

Joining a group/party/guild of other players

  1. A player wants to join a party, so they press a button requesting to join which sends an RPC to the server
  2. The server then sends an RPC to the "party leader" telling them that a new player wants to join
  3. This shows a popup on the "party leader's" client asking them to Accept or Deny the request. Showing this popup requires yielding control back to the engine, so it can draw the buttons and take input.
  4. When the "party leader" clicks "Accept" this sends an RPC to the server saying they are accepted
  5. The server sends an RPC to the original player saying they have been accepted

Again, since so much needed to happen asynchronously, none of those RPC's could have return values.

@Faless
Copy link

Faless commented Dec 28, 2020

I was writing a comment about the asynchronous requirements of such a feature but I see @dsnopek already explained it quite well.

In general, I too think that this is not worth it, given that it won't be commonly used, might end up leading developers into pitfalls, and when a result is actually needed, it can be worked around in GDScript (even as a reusable component with it's own semantics if needed).

@bfelbo
Copy link

bfelbo commented Dec 28, 2020

Thanks for the detailed response @dsnopek! I completely agree that this would need to be implemented with yield (or await in 4.0) and a configurable timeout.

I might not have explained my examples clearly enough - the RPCs would return a quick success/error, not wait for other players' interactions. For instance, entering a dungeon would respond success along with any info needed to start streaming the dungeon data (your step 2) or respond with an error (e.g. "a party member is offline"). Similarly, joining a party would respond success and show the dialog to the party leader (your step 2) or respond with an error (e.g. "party is full"). Other common errors are due to race conditions such as "item already looted" or "NPC is busy".

Handling errors and race conditions is surprisingly painful. If Godot doesn't have a way to return success/error, then you'd need extra server-callable functions on the client for each RPC that requires the server to communicate the response. More importantly, the developer then needs to handle the complexity of timeouts, the response function somehow called 2+ times at the same time, and many other weird cases.

I agree that simple games could use a separate HTTPS server (although that adds complexity too). However, that wouldn't work for any multiplayer game, where the game server needs to communicate in-game success/error responses.

All this being said, I think it's completely fair if this is out of scope for core :)

@jonbonazza
Copy link

In my mind, without some type of simple request/response support in RPC, it will always be limited to use in small peer-to-peer games running on the same sub-net.

This is just untrue. Pretty much every major MMO or FPS uses rpcs without return values. As I mentioned, there just arent very many cases where they are useful, and there is generally always a better alternative.

The use cases that @bfelbo outlined are also not good ones for various reasons, sone of which having already been mentioned.

@tx350z
Copy link
Author

tx350z commented Dec 28, 2020

Reading the responses I realize that the rabbit got lost along the way. Perhaps a more complicated example would help explain the need? I also realize that even if the capability for an rpc_id call to return a value (which is what my request is about) was to be implemented, it would not be available soon enough to meet my time line. Therefore, I'm closing the request.

For my needs I've dedicated to create a server connector class that encapsulates a combination of RPC calls for push type communications (good application for RPC) and TCP connections that natively support request/response type communications.

Thanks all.

@nathanfranke
Copy link
Contributor

I don't think this should be closed. While it is an uncommon use case, there are cases where the resulting code is much more readable with this feature. In addition, there is no technical limitation preventing this from happening. rpc can return a coroutine that completes either instantly or when the server sends the response.

Fetching chunks example

Client requests chunk data for some Vector3 position. Workaround is trivial, since usually the logic for rendering the loaded chunk is self-contained.

Current code needed:

@rpc(any_peer) func fetch_chunk(pos: Vector3) -> void:
	receive_chunk.rpc_id(get_multiplayer().get_remote_sender_id(), pos, PackedByteArray([0, 1, 2, 3]))

@rpc func receive_chunk(pos: Vector3, data: PackedByteArray) -> void:
	pass # Chunk loading logic.

func test() -> void:
	fetch_chunk(Vector3(0, 1, 2))

Proposed alternative:

@rpc(any_peer func fetch_chunk(pos: Vector3) -> PackedByteArray:
	return PackedByteArray([0, 1, 2, 3])

func test() -> void:
	var data = await fetch_chunk(Vector3(0, 1, 2))
	# Chunk loading logic

Complex f(x) example

Client requests a function result given an input, where the result needs to be passed to the same function that made the call.

Current code needed:

signal hash_value_received(id, result)

@rpc(any_peer) func hash_value(id: int, input: String) -> void:
	# Pretend this is an expensive or secret function call. Calling on the client is either not feasible or not possible.
	var result := input.hash()
	receive_hash_value.rpc_id(get_multiplayer().get_remote_sender_id(), id, result)

@rpc func receive_hash_value(id: int, result: int) -> void:
	hash_value_received.emit(id, result)

func test() -> void:
	# var dialog = HelperClass.open_dialog("Please input some text")
	var id := randi()
	hash_value.rpc(id, "hello")
	var result
	while true:
		var r = await hash_value_received
		if r[0] == id:
			result = r[1]
			break
	# This code needs to be here if we need some context from the last call, such as closing a dialog.
	# dialog.queue_free()
	print("Result is ", result)

Proposed alternative:

@rpc(any_peer) func hash_value(input: String) -> int:
	# Pretend this is an expensive or secret function call. Calling on the client is either not feasible or not possible.
	var result := input.hash()
	return result

func test() -> void:
	# var dialog = HelperClass.open_dialog("Please input some text")
    var result = await hash_value("hello")
	# This code needs to be here if we need some context from the last call, such as closing a dialog.
	# dialog.queue_free()
	print("Result is ", result)

@dsnopek
Copy link

dsnopek commented Aug 1, 2022

@nathanfranke How do we handle the "what if the server never responds" case with your co-routine proposal?

@nathanfranke
Copy link
Contributor

nathanfranke commented Aug 1, 2022

It will just wait forever.

Cases where this would happen:

  • The server-side RPC has await forever or something
  • The RPC signatures are different on client/server (could be an additional check)
  • Bug with Godot
  • A malicious client trying to crash a server or another player instance. (Src @Faless)

Edit: So we'll need some safety, hmm

@Faless
Copy link

Faless commented Aug 1, 2022

Cases where this would happen:

  • A malicious client trying to crash a server or another player instance.

@dsnopek
Copy link

dsnopek commented Aug 1, 2022

Cases where this would happen:

Also:

  • Network issues

Personally, I don't think we should introduce some engine-level networking functionality, where the developer can't provide a nice way for their game to handle network issues (not to mention the other reasons it may never respond).

With what we have currently, the developer can (and should!) setup a system where they start a timer when making an RPC that they expect to followed by another RPC in response. And if it times out without a response, they can take some action that makes sense in context. Maybe they retry a couple times? Maybe they show an error message and ask the player to try again later? An addon to manage this could be created.

I suppose they could still do that with your proposal, but it's not ideal. What if the server does end up responding, but way after the timeout? Then the developer needs to know to check some flag after the await to see if they should still do the next action. And if the server really never does respond, we could end up with a rather large collection of co-routines that never resume, and so never get freed from memory.

But mainly, I don't think we want to create an API that gives the illusion that the developer can depend on this, when in the real world, they can't.

@nathanfranke
Copy link
Contributor

Network issues shouldn't be an issue with reliable rpc, and I don't think this should be supported with unreliable RPC.

With what we have currently, the developer can (and should!) setup a system where they start a timer when making an RPC that they expect to followed by another RPC in response

I don't agree that the solution to X being potentially insecure is to rely on game developers to implement X.

@dsnopek
Copy link

dsnopek commented Aug 1, 2022

Network issues shouldn't be an issue with reliable rpc

Network issues can still cause reliable RPC's to not arrive.

@tx350z
Copy link
Author

tx350z commented Aug 1, 2022

I'll chime in since I wrote the OP. IMO, RPC as implemented is fine for smallish games where every node contains the entire game logic (where things like master, puppet, etc. make sense). Again IMO, the current RPC model does not easily support "ecosystem scale" MMO applications. But that is OK, most games will never be truly MMO.

In the end I chose not to use RPC since it doesn't meet my needs. I chose instead to use mid-level communications with threaded classes, FIFO queues, custom event notification, etc. Works well, reliable, and superior error handling. However it requires 10x the amount of code with functional logic splattered across a large number of classes.

@Faless
Copy link

Faless commented Aug 1, 2022

So, I'm re-opening for now to keep the discussion going, even if I still don't think we should implement this feature, at least not in the short run. Some details on a potential implementation follow:

First things, Godot doesn't really have the concept of co-routines, that's a GDScript thing, it's not exposed at Godot/ClassDB level.
So to do that, RPC would need to return an Object (let's call it RPCResult) which emits a signal when the result is received.
Additionally, if error handling is desired, the signal should contain an array (error + result/nil) or we should store those info in the object itself.
Something on the line of:

@rpc(any_peer func fetch_chunk(pos: Vector3) -> PackedByteArray:
	return PackedByteArray([0, 1, 2, 3])

func test() -> void:
	var result = fetch_chunk.rpc(Vector3(0, 1, 2))
	await result.completed
	if result.is_error():
		print(result.error)
		return
	var data = result.data
	# Chunk loading logic

Then, to make the system kind of safe, we need to:

  • Keep track of RPCResults, assigning IDs to them (transferred over wire)
  • Receiving end will send the result with the same ID back to the sending peer.
  • The sending peer needs to expire RPCResult after some time (setting error and emitting completed)
  • If response is received in time set result and emit completed. (Note: how would it handle broadcast? I guess only rpc_id make sense?).
  • There should be a limit on the maximum pending RPCResult after which old stale one should be dropped.
  • There will be responses arriving too late, we should just drop them silently.

This is quite the work, and as I mentioned, I think the use case is limited, still, if we want to give this a try, all this should be implementable in GDScript. The RPCs will be a bit less performant, but given the use case I don't think that's a deal breaker.
Then, if we see that the GDScript implementation works well and is proving useful, it might make sense including it in the multiplayer module to squeeze more performance out of it.

@Faless Faless reopened this Aug 1, 2022
@Calinou Calinou removed the archived label Aug 1, 2022
@nathanfranke
Copy link
Contributor

nathanfranke commented Aug 1, 2022

What is an example error message? I don't think explicit error handling is worth it if the only cause is a malicious client or engine bug. It would be better to have the errors in the engine with ERR_FAIL_etc.

func test() -> void:
	var result = await my_rpc.rpc()
	# Is it possible to automatically "return" on an error?

Edit: And if it isn't possible, we shouldn't immediately go to "how to work around this" but rather how to keep this intuitive for users, even if it involves modifying GDScript.

Note: how would it handle broadcast? I guess only rpc_id make sense?

If server_relay is disabled, rpc is still valid (doesn't rpc call rpc_id(0, ...) internally anyways?)

@nathanfranke
Copy link
Contributor

Network issues can still cause reliable RPC's to not arrive.

Not true, if so should be documented

Reliable: the function call will arrive no matter what, but may take longer because it will be re-transmitted in case of failure.

@Faless
Copy link

Faless commented Aug 1, 2022

Is it possible to automatically "return" on an error?

That would be exception handling. So no, it's not supported in GDScript.

What is an example error message?

Yeah, It doesn't have to be a message just detecting the error is fine (though differentiating between timeout and wrong return type might be nice):

func test() -> void:
	var result = fetch_chunk.rpc(Vector3(0, 1, 2)).completed
	# await result.completed # EDIT: We could skip this and do it above, but will need to be careful about reference count
	if result.is_error():
		return
	var data = result.data
	# Chunk loading logic

If server_relay is disabled, rpc is still valid (doesn't rpc call rpc_id(0, ...) internally anyways?)

Yeah, wrong terminology, let me rephrase:

"how would it handle broadcast/multicast? I guess only unicast make sense?."

@dsnopek
Copy link

dsnopek commented Aug 1, 2022

Responding to @nathanfranke:

Network issues can still cause reliable RPC's to not arrive.

Not true, if so should be documented

Imagine you send a reliable RPC, and then the user loses their internet connection. There's nothing that Godot can do to make the RPC arrive.

If you're using ENet, for example, it won't immediately know that there is no connection because it uses UDP (a connectionless protocol), but it does have a mechanism to eventually detect that messages are no longer arriving. At that point, the SceneTree.server_disconnected signal will get emitted.

But in the interim, between when the internet connection is lost and ENet figures that out, you could send any number of reliable RPCs, and they won't arrive.

Reliable: the function call will arrive no matter what, but may take longer because it will be re-transmitted in case of failure.

Heh, yeah, that probobly should be changed! Godot does not possess any special magic that can make an RPC arrive "no matter what" :-)

@iRumba
Copy link

iRumba commented Sep 29, 2023

I create rpc with returnig. By callbacks and without waiting

at first need to add it class to autoload. It generic rpc func

extends Node

var _messages_callbacks: Dictionary = {}

class ErrorResponse extends Object:
	var error: Errors
	var message: String

func configure_handle(message: int, callback: Callable):
	if _messages_callbacks.has(message):
		printerr("Message " + str(message) + " already has handler")
		return
		
	_messages_callbacks[message] = callback

@rpc("any_peer")
func send(message: int, data: Dictionary):	
	var sender = multiplayer.get_remote_sender_id()
		
	if !_messages_callbacks.has(message):
		printerr("Peer ", sender, " send not handled message ", message)
		
	else:	
		var callback: Callable = _messages_callbacks[message]
		var res = callback.call(sender, data)
	pass

message is int, but you can change it to String if you want. Also data has type Dictionary, but maybe PackedByteArray is better

also need this class for configuring callbacks and serializing custom messages

extends Object

class_name Message


static func register_message_type(type):
	MessageConfiguration.add_message(type)
	
static func register_response(type, response_type):
	MessageConfiguration.add_response(type, response_type)
	
static func create_response_of(inst: Message) -> Message:
	return MessageConfiguration.get_response_type(inst.get_script()).new()
	
static func create(type) -> Message:
	if !MessageConfiguration.is_registered(type):
		printerr("Message type ", type.resource_path, " not registered")
		return null
	
	var inst = type.new()
	
	return inst

static func get_id_by_instance(inst) -> int:
	return MessageConfiguration.get_id(inst.get_script())
	
static func create_by_id(id: int):
	if !MessageConfiguration.has_id(id):
		printerr("Message type id ", id, " not registered")
		return null
		
	var inst = MessageConfiguration.get_message_type(id).new()
	
	return inst

func send():
	var data = serialize()
	if data == null:
		return
	
	Exchange.send.rpc(__get_message_id(), data)
	pass

func send_to(peer_id: int):
	var data = serialize()
	if data == null:
		return
		
	Exchange.send.rpc_id(peer_id, __get_message_id(), data)
	pass
	
func handle_message(callback: Callable, need_data: bool = false, need_peer: bool = false):
	var message_id: int = __get_message_id()
	if !MessageConfiguration.has_id(message_id):
		printerr("Message not registered")
		return
	
	Exchange.configure_handle(message_id, create_message_callback(callback, need_data, need_peer))
	
func handle_response(handler: Callable, need_data: bool = false, need_peer: bool = false):
	var message_id: int = __get_message_id()
	if !MessageConfiguration.has_id(message_id):
		printerr("Message not registered")
		return
		
	if !MessageConfiguration.has_response(self.get_script()):
		printerr("Response not registered")
		return
		
	var resp_message_type = MessageConfiguration.get_response_type(self.get_script())
	var resp_message_id = MessageConfiguration.get_id(resp_message_type)
	
	Exchange.configure_handle(resp_message_id, create_message_callback(handler, need_data, need_peer))
	
func create_response_callback(callback: Callable, need_data: bool, need_peer: bool) -> Callable:
	return func(peer_id: int, msg: Dictionary):
		var c = callback
		var message = deserialize(msg)
		
		if message is Exchange.ErrorResponse:
			c = create_error_callback(peer_id, message)
		else:
			if need_data:
				c = c.bind(message)
			
			if need_peer:
				c = c.bind(peer_id)
			
		var res = c.call()
		
		if res is Message:
			res.send_to(peer_id)
		
func create_error_callback(peer_id: int, error: Exchange.ErrorResponse) -> Callable:
	return func():
		printerr("Message: ", get_script().resource_path, " Peer: ", peer_id, " Error: ", Exchange.Errors.keys()[error.error], " Message: ", error.message)
	
func create_message_callback(callback: Callable, need_data: bool, need_peer: bool) -> Callable:
	return func(peer_id: int, msg: Dictionary):
		var c = callback
		if need_data:
			c = c.bind(deserialize(msg))
		
		if need_peer:
			c = c.bind(peer_id)
			
		var res = c.call()
		
		if res is Message:
			res.send_to(peer_id)
	
func serialize() -> Dictionary:
	return inst_to_dict(self)
	
func deserialize(dict: Dictionary) -> Message:
	var msg = dict_to_inst(dict)
	return msg
	
func __get_message_id() -> int:
	return get_id_by_instance(self)
	
func create_response() -> Message:
	return create_response_of(self)
	
static func instance():
	push_error("Not implemented")
	printerr("Call instance on class extends Message")
	
class MessageConfiguration extends Object:
	# key is int message identifier and value is type of message that extends Message
	# for example { 1: MyMessage, 2: OtherMessage }
	static var types_by_ids: Dictionary = {}
	
	static var ids_by_types: Dictionary = {}
	static var responses_types: Dictionary = {}
	
	static func add_message(type):
		if is_registered(type):
			printerr("Message type ", type.resource_path, " already registered")
			return
			
		var index = ids_by_types.size()
			
		types_by_ids[index] = type
		ids_by_types[type] = index
		
	static func add_response(type, response_type):
		if !is_registered(type):
			printerr("Message type ", type.resource_path, " not registered")
			return
			
		if !is_registered(response_type):
			printerr("Message type ", response_type.resource_path, " not registered")
			return
			
		if responses_types.has(type):
			printerr("Response for message type ", type.resource_path, " already registered")
			return
			
		responses_types[type] = response_type

	static func get_message_type(id: int):
		if !is_registered(id):
			printerr("Message with id ", id, " not found")
			return
		
		return types_by_ids[id]
	
	static func get_response_type(type):
		if !responses_types.has(type):
			printerr("Response for message type ", type.resource_path, " already registered")
			return
			
		return responses_types[type]
		
	static func is_registered(type) -> bool:
		return ids_by_types.has(type)
		
	static func has_id(id: int) -> bool:
		return types_by_ids.has(id)
		
	static func has_response(type) -> bool:
		return responses_types.has(type)
		
	static func get_id(type) -> int:
		return ids_by_types[type]

also need message types that extend Message

Login message

extends Message

class_name LoginPlayer

var player_name: String

func send():
	send_to(1)

login result message (in my case need only name)

extends Message

class_name LoginResult

enum Results {
	FAIL = 0,
	LOGGED_IN = 1,
	ALREADY_LOGGED = 2
}

var result: Results

also need configuring messages before using. For example in some autoloaded script

func _ready() -> void:
	__configure()

func __configure():
	Message.register_message_type(LoginPlayer)
	Message.register_message_type(LoginResult)
	Message.register_response(LoginPlayer, LoginResult)
	pass

Usage

Login screen

func login():
	var message: LoginPlayer = Message.create(LoginPlayer)
	message.player_name = $CenterContainer/VBoxContainer/PlayerName.text
	message.handle_response(logged_in, true, false)
	message.send()

func logged_in(login_result: LoginResult):
	get_tree().change_scene_to_packed(Resources.Screens.ClientMain)

and server processing

func _ready() -> void:
	var message: LoginPlayer = Message.create(LoginPlayer)
	message.handle_message(_on_player_logged, true, true)

func _on_player_logged(peer_id: int, login: LoginPlayer) -> LoginResult:
	var resp: LoginResult = login.create_response()
	if !players.has(peer_id):
		resp.result = LoginResult.Results.ALREADY_LOGGED
	else:
		var player = Player.new()
		player.player_name = login.player_name
		players[peer_id] = player
		resp.result = LoginResult.Results.LOGGED_IN
		
	return resp

What happened

Client creates message with data for authorization and subscribes to response

Server subscribes to message with authorization data and returns value (type that extends message too)

Additional, when server creates response message, it can subscribe to response of response :)
so, you can configure protocols :)

But this way have problems. It don't support channels, security (@rpc("any_peer")), and other variativity of rpc protocol in godot

But may be it can be solved.

@bryanmylee
Copy link

I'm building a server-authoritative system with WebRTC, where the server is simply the multiplayer authority in the network.

When a client wants to emit an event, I need to be able to indicate whether the event is accepted or rejected by the server. Without return values on the RPC calls, I'd have to set up my own signalling system to resolve and reject different events on the emitting client.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants