Spawning and Ownership - RoyasDev/EzNet GitHub Wiki

In this tutorial we will learn how to spawn things across the network with EzNet and how to manage the ownership of objects as well.

To get started what we need to do is to create a new project in Godot 4.0+

image

Once the project is created we will make a 2D scene

image

Now save the scene and name it whatever you like. I will name mine ball pit

image

Then we will go to the AssetLib tab in the top center of the screen

image

In the AssetLib search for and install EzNet by Royas If its below version 1.0.2 you will have to make the connection menu yourself. To learn how check out our Move Cube example

image

Go back to our scene and create an empty node and call it Ball_Pit_Network_Manager

image

Now create and attach a script to the Ball_Pit_Network_Manager called ball_pit_network_manager.gd script

image

In our ball_pit_network_manager script we will extend the NetworkManager class. Save the script and go back to the scene

image

Click on the Ball_Pit_Network_Manager node we created and you will now notice it has two properties in the Inspector tab. Networker and Ticks per Second. The Networker determines what multiplayer peer EzNet will use. It could use ENet, GodotSteam, WebSockets or whichever other multiplayer peer we would like. The Ticks per Second is kind of like the frame rate of the network. It will determine how frequently things on the network are updated like sync vars and batched spawns.

image

Because the Networker determines what multiplayer peer we are going to use, we have to assign something to it. Luckily there is a pre-made ENet networker already made for us. Just go to the folder EzNet > resources > enet_networker.tres and drag the enet_networker.tres into the Networker property in the inspector

image

Set the Networker's max clients to 2

image

Now lets make the connection menu. Make a Control Node just in our scene and Call it UI

image

Then go to the folder EzNet > scenes > connection_menu.tscn and drag it underneath our UI node

image

Then we will create another control underneath the UI Control Node and we will call this SpawnButtons

image

Under the SpawnButtons Node we will make 2 Nodes ServerSpawner and ClientSpawner

image

Now we will put a button underneath the ServerSpawner and set it's text to "Spawn Server Ball"

image

Do that again underneath the ClientSpawner, but set its text to "Spawn Client Ball", set its Anchors Preset to Top Right and set its x position to 1010.0

image

Create a script on either the ServerSpawner or the ClientSpawner called spawner_network.gd

image

Add the script to both the ServerSpawner and the ClientSpawner

image

In the script we will extend NetworkObject

image

Save the script and go back to the scene. You will notice that the ServerSpawner and the ClientSpawner have two new properties as well. Network Manager Name and Set Server to Owner on Disconnect. The Network Manager Name is how the NetworkObject finds the NetworkManager we created. The Set Server to Owner on Disconnect when it is checked returns this NetworkObject to the ownership of the server when the player that owns it disconnects. Usually it would just destroy the object. This is handy for some games, but not for what we are doing today. So we will just leave it unchecked.

image

Then we will make scenes out of our Ball_Pit_Network_Manager, ServerSpawner and ClientSpawner

image

Now go to the Project tab at the top and click Project Settings...

image

In the window that pops up go to the Globals tab at the top. Then open the Autoload tab just beneath it. Click the folder icon between the Path: and Node Name: and then select the ball_pit_network_manager.tscn scene that we just created.

image

Copy the name in the Node Name: field after you select the ball_pit_network_manager scene

Once you have selected the ball_pit_network_manager.tscn click the +Add button

image

Close out of the project settings window and delete the Ball_Pit_Network_Manager, ServerSpawn and Client Spawn Nodes.

image

Paste the name you just copied into the Network Manager Name field of the server_spawner.tscn and the client_spawner.tscn scenes. Then save those scenes

image

Click on the ConnectionMenu and set the path to /root/BallPitNetworkManager or whatever the name was you copied

image

Time to make the balls we are going to spawn. Get yourself an image of a ball. I will be using this football/soccer ball from pixabay by OpenClipart-Vectors

Once you have your ball image go back to Godot and make a RigidBody2D Node and name it Ball. Give it Sprite2D and CollisionShape2D Nodes underneath it

image

Give the CollisionShape2D a New CircleShape2D

image

Set the texture on the Sprite2D to be the ball you selected and scale it to be the same size as the CircleCollider2D

image

Now turn our ball into a scene and delete it from the level

image

Last thing we have to do before we get into the actual coding part is to make the ball pit itself. To do that we will make three StaticBody2Ds, with a Sprite2D and a CollisionShape2D underneath them

image

Set all of the Sprite2D's Texture to a CanvasTexture

image

Then set all of the CollisionShape2D's Shapes to be a new RectangleShape2D

image

Rename the first StaticBody2D to Left, the second to Right and the last one to Bottom

image

The Sprite2D under the Left StaticBody2D set its position to x: 8.0 y: 324.0 and its scale to x: 16.0 y: 648.0

image

Set the Left's CollisionShape2D's Size to x: 15.0 y: 648.0 and its position to x: 8.0 y: 324.0

image

Set the Rights Sprite2D's Position to x: 1144.0 y: 324.0 and Scale to x: 16.0 y: 648.0

image

Set the Rights CollisionShape2D's Size to x: 15.0 y: 648.0 and its Position to x: 1144.0 y: 324.0

image

Set the Bottom's Sprite2D's Position to x: 576.0 y: 640.0 and its Scale to x: 1120.0 y: 16.0

image

And finally set the Bottom's CollisionShape2D's Position to x: 576.0 y: 640.0 and its Size to x: 1120.0 y: 16.0

image

And finally drag the UI to be below all of the static bodies. I used a CanvasTexture, because I was lazy and didn't want to make a proper white square texture myself. But this causes the issue of the textures rendering over the UI

image

Now that we are done all of our prep work we can finally move on to coding our ball_pit_network_manager.gd and our spawner_network.gd to tie this all together!!

Go into your ball_pit_network_manager.gd script and add the line near the top

@onready var spawner_buttons_parent : Node = get_node("/root/Node2D/UI/SpawnButtons")

This is to get the node that we will want to parent our spawner buttons to

Now add 2 more @onready's to preload the spawner buttons

@onready var server_spawner := preload("res://server_spawner.tscn")
@onready var client_spawner := preload("res://client_spawner.tscn")

These are all the variables in our ball_pit_network_manager.gd script we will need. They should look something like this

image

Now lets make a function called _on_server_started(). This will get called on the server when the server starts up and on the client when the client connects to the server. We are doing logic here so that we know that the server is ready to go before we do any networking logic

image

In this function we will make an if statement that will check if we are the server and if we are well will spawn the server spawner button. We will do that by calling the _network_spawn_object function as an rpc to send it to every client and passing in an array that contains a dictionary. The dictionary will have our arguments that we want our object to spawn with. In this case it will have the args "spawn_button" which will be set to "server" and "parent_node_path" which will be set to the path of the node we got @onready earlier spawner_buttons_parent.get_path()

	if is_server: 
		_network_spawn_object.rpc(
			[
			{
			"spawn_button" : "server",
			"parent_node_path" : spawner_buttons_parent.get_path()
		}
		]
		)

Since we are the server we can just spawn whatever we want. The server is in control of everything, but when the client connects we don't want them to be able to just spawn things for everyone. That could lead to a lot of cheating. So the server won't allow clients to spawn things for others. In order to spawn something the clients have to send an rpc to the server to request permission to spawn it and then if they are allowed to the server will then spawn it for everyone. The server can validate the spawn using a validator, but we will get to that in a different tutorial. We will now send the spawn request in our else block of the if statement we just made.

	else:
		_request_spawn_object.rpc_id(1, 
		{
			"spawn_button" : "client",
			"parent_node_path" : spawner_buttons_parent.get_path()
		}
		)

Our completed _on_server_started function should now look like this

func _on_server_started():
	if is_server: 
		_network_spawn_object.rpc(
			[
			{
			"spawn_button" : "server",
			"parent_node_path" : spawner_buttons_parent.get_path()
		}
		]
		)
	else:
		_request_spawn_object.rpc_id(1, 
		{
			"spawn_button" : "client",
			"parent_node_path" : spawner_buttons_parent.get_path()
		}
		)

We now have to connect our _on_server_started function to the signal on_server_started in the _ready() function

func _ready() -> void:
	on_server_started.connect(_on_server_started)

Now how does EzNet know what to do with those args we gave the spawner? They seem pretty specific to our game. Well it doesn't. So we have to override the _spawn_object function that exists in the NetworkManager class. It just has some basic default behaviour and it was built to be overriden based on the specific needs of your game.

To override the _spawn_object function all you need to do is create the function again in our ball_pit_network_manager.gd script.

func _spawn_object(spawn_args : Dictionary) -> Node:
    pass

Since we have overriden our _spawn_object function and only put pass in it nothing will happen if we try to spawn something. So we need to fill it out with the specific logic we will need for our game. We will start by defining some variables at the top of the function.

func _spawn_object(spawn_args : Dictionary) -> Node:
	var owner_id : int = 1
	var spawned_obj
	var resource_path : String = "dummy data so this still tries to spawn on newly connected clients"

Ok, now lets explain why we need these variables. The owner_id the server automatically attaches to any _request_spawn_object call and sets it to the network_id of the client that sent the request. The spawned_obj will store a reference to the node we are about to spawn and the resource_path is used for some internal EzNet logic. So if resource_path is empty EzNet will not sync the spawn to newly connected clients. Which is why we have dummy data in there.

Now we will get the automatically attached owner_id and set our var we made at the top of the function to it

if spawn_args.has(OWNER_ID_KEY):
	owner_id = spawn_args[OWNER_ID_KEY]

Then after that we will check if the spawn_args has that key we had set earlier called "spawn_button" and if it does it will spawn the buttons

if spawn_args.has("spawn_button"):
	if spawn_args["spawn_button"] == "server":
		spawned_obj = server_spawner.instantiate()
	else:
		spawned_obj = client_spawner.instantiate()

Then if it doesn't have the "spawn_button" key we will check if it has the key "resource_path". Since EzNet uses that key internally we created a const variable for it to avoid typos called RESOURCE_PATH_KEY. So if the spawn has the RESOURCE_PATH_KEY we will then spawn whatever is at that path

elif spawn_args.has(RESOURCE_PATH_KEY):
	resource_path = spawn_args[RESOURCE_PATH_KEY]
		
	spawned_obj = load(resource_path).instantiate()

Now that we have spawned the Node based on the type of spawn request, we need to check if its of type NetworkObject. This is because our spawn system supports both NetworkObject and regular Node types. If it isn't a NetworkObject it won't have all of the networking logic tied to it. Like syncing the spawn to newly connected clients or getting destroyed when the client who owns them disconnects. If it is a NetworkObject though we will have to set some values on it to make it work correctly. like the resource_path, owner_id and the spawn_args. We will set those right after the check.

if spawned_obj is NetworkObject:
	spawned_obj.resource_path = resource_path
	spawned_obj.owner_id = owner_id
	spawned_obj.spawn_args = spawn_args

Then we will check for a spawn_arg that we haven't made yet. The "ball_spawn_position" arg. This will be to set the position of the ball when we spawn it. If it has the arg we will set the position of the Sprite2D and the CollisionShape2D to the "ball_spawn_position".

if spawn_args.has("ball_spawn_position"):
	spawned_obj.get_node("Sprite2D").position = spawn_args["ball_spawn_position"]
	spawned_obj.get_node("CollisionShape2D").position = spawn_args["ball_spawn_position"]

Now we just have to add the spawned node to the scene tree and then return it to complete the function. To do that we will check if the spawn_args has a key called "parent_node_path" and if it does we will set that to be the parent node. If it doesn't we will just add the node to the current scene.

if spawn_args.has("parent_node_path"):
	var parent_node = get_node(spawn_args["parent_node_path"])
		
	if is_instance_valid(parent_node):
		parent_node.add_child(spawned_obj)
else:
	get_tree().current_scene.add_child(spawned_obj)
	
return spawned_obj

That completes our ball_pit_network_manager.gd script so here is the finished product

extends NetworkManager

@onready var spawner_buttons_parent : Node = get_node("/root/Node2D/UI/SpawnButtons")
@onready var server_spawner := preload("res://server_spawner.tscn")
@onready var client_spawner := preload("res://client_spawner.tscn")


# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	on_server_started.connect(_on_server_started)

func _on_server_started():
	if is_server: 
		_network_spawn_object.rpc(
			[
			{
			"spawn_button" : "server",
			"parent_node_path" : spawner_buttons_parent.get_path()
		}
		]
		)
	else:
		_request_spawn_object.rpc_id(1, 
		{
			"spawn_button" : "client",
			"parent_node_path" : spawner_buttons_parent.get_path()
		}
		)

func _spawn_object(spawn_args : Dictionary) -> Node:
	var owner_id : int = 1
	var spawned_obj
	var resource_path : String = "dummy data so this still tries to spawn on newly connected clients"
	
	if spawn_args.has(OWNER_ID_KEY):
		owner_id = spawn_args[OWNER_ID_KEY]
	
	if spawn_args.has("spawn_button"):
		if spawn_args["spawn_button"] == "server":
			spawned_obj = server_spawner.instantiate()
		else:
			spawned_obj = client_spawner.instantiate()
	elif spawn_args.has(RESOURCE_PATH_KEY):
		resource_path = spawn_args[RESOURCE_PATH_KEY]
		
		spawned_obj = load(resource_path).instantiate()
	
	if spawned_obj is NetworkObject:
		spawned_obj.resource_path = resource_path
		spawned_obj.owner_id = owner_id
		spawned_obj.spawn_args = spawn_args
	
	if spawn_args.has("ball_spawn_position"):
		spawned_obj.get_node("Sprite2D").position = spawn_args["ball_spawn_position"]
		spawned_obj.get_node("CollisionShape2D").position = spawn_args["ball_spawn_position"]
	
	if spawn_args.has("parent_node_path"):
		var parent_node = get_node(spawn_args["parent_node_path"])
		
		if is_instance_valid(parent_node):
			parent_node.add_child(spawned_obj)
	else:
		get_tree().current_scene.add_child(spawned_obj)
	
	return spawned_obj

For our last script we are onto our spawner_network.gd script. To start this script we will first @export the position that we would like to spawn the ball at

extends NetworkObject

@export var ball_spawn_position : Vector2 = Vector2.ZERO

Then we will make a _on_network_ready() function. This function gets called when the NetworkObject has been spawned and has completed all of it's set-up. So we can safely put our networking logic into it. The first thing we will do in this function is check if we are the owner.

func _on_network_ready():
	if !_is_owner(): return

We don't want anyone to be able to click our spawn button if they don't own it. So that's why we check first if this client owns it. Then after that we will get a hold of the child Button we made on our server_spawner.tscn and our client_spawner.tscn and connect a function so that when the button is pressed it spawns the ball

var spawn_button : Button = get_node("Button")
	
spawn_button.pressed.connect(_on_button_pressed)

Thats it for our _on_network_ready() function. We now need to make the _on_button_pressed() function that spawns the ball when the button is clicked. In this function we will use our 3rd way of spawning in EzNet. We had the direct spawning from the server before with the _network_spawn_object.rpc function and we had the _request_spawn_object.rpc_id for the clients to request the spawn. EzNet also has a nice helper function to help craft our spawn requests and it will automatically determine which spawn method we should use. To use this helper function all we have to do is call network_manager._request_spawn_helper and pass in a path to the thing we want to spawn and then attach any additional spawn_args that we may want.

For our game we will pass in the ball.tscn spawn path and we will add the additional spawn_arg of the "ball_spawn_position" and we will set that to the ball_spawn_position @export variable

func _on_button_pressed():
	network_manager._request_spawn_helper("res://ball.tscn", {
		"ball_spawn_position" : ball_spawn_position
	})

Now the final steps to make this script work is to connect our on_network_ready signal to our _on_network_ready function and to call super() in our ready function

func _ready() -> void:
	on_network_ready.connect(_on_network_ready)
	super()

We connect the _on_network_ready signal before we call super() because sometimes EzNet is too fast when setting up the NetworkObject and it can call the _on_network_ready before it has a chance to connect to the signal if we call super() first. super() is absolutely critical for setting up the NetworkObject if it isn't called the it won't function as expected.

Now open up the server_spawner.tscn scene we made and set the Ball Spawn Position to x: 100 y: 100

Server Spawner

Then open up the client_spawner.tscn scene and set its Ball Spawn Position to x: 1052 y: 100

Client Spawner

All thats left is to test the project! To do this we need to go to the Debug menu at the top left of Godot and click Customize Run Instances...

image

Check mark the Enable Multiple Instances and set the number to 3. Then click OK

image

Run the project and you will have 3 game windows pop up

image

Press host on one and connect on another and start clicking those spawn buttons!

ball_spawning

Notice how the Spawn Client Ball button appears and disappears when the client connects and disconnects, but the balls only show up when the are spawned while the client is connected? That is because the Spawn Server Ball button and the Spawn Client Ball button are NetworkObjects, but the balls aren't. So the buttons are synced between connection and disconnection, but the balls won't be.