├── .gitattributes ├── .gitignore ├── LICENSE ├── Lobby.gd ├── Lobby.tscn ├── Player.gd ├── Player.tscn ├── README.md ├── Scene1.gd ├── Scene1.tscn ├── Scene2.gd ├── Scene2.tscn ├── SceneSafeMultiplayerIcon.png ├── addons └── scene_safe_multiplayer │ ├── LICENSE │ ├── MultiplayerSpawner.svg │ ├── MultiplayerSpawner.svg.import │ ├── MultiplayerSynchronizer.svg │ ├── MultiplayerSynchronizer.svg.import │ ├── plugin.cfg │ ├── register_plugin.gd │ ├── scene_safe_mp_manager.gd │ ├── scene_safe_mp_spawner.gd │ └── scene_safe_mp_synchronizer.gd ├── icon.svg ├── icon.svg.import └── project.godot /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize EOL for all files that Git considers text files. 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | 4 | # Godot-specific ignores 5 | .import/ 6 | export.cfg 7 | export_presets.cfg 8 | 9 | # Imported translations (automatically generated from CSV files) 10 | *.translation 11 | 12 | # Mono-specific ignores 13 | .mono/ 14 | data_*/ 15 | mono_crash.*.json 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 TestSubject06 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Lobby.gd: -------------------------------------------------------------------------------- 1 | extends Control 2 | 3 | 4 | # Called when the node enters the scene tree for the first time. 5 | func _ready(): 6 | pass # Replace with function body. 7 | 8 | 9 | # Called every frame. 'delta' is the elapsed time since the previous frame. 10 | func _process(_delta): 11 | $HBoxContainer/Button2.disabled = (!$HBoxContainer/Address.text || !$HBoxContainer/Port.text); 12 | 13 | func _on_host_pressed(): 14 | var peer = ENetMultiplayerPeer.new(); 15 | peer.create_server(19019, 4); 16 | if(peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED): 17 | return; 18 | multiplayer.multiplayer_peer = peer; 19 | print("server started"); 20 | get_tree().change_scene_to_packed(load("res://Scene1.tscn")); 21 | pass # Replace with function body. 22 | 23 | 24 | func _on_connect_pressed(): 25 | var peer = ENetMultiplayerPeer.new(); 26 | peer.create_client($HBoxContainer/Address.text, int($HBoxContainer/Port.text), 0, 0, 0); 27 | if(peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED): 28 | return; 29 | 30 | multiplayer.multiplayer_peer = peer; 31 | 32 | # Connection timeout 33 | var timeout = Time.get_ticks_msec() + 15000; 34 | while(peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED): 35 | if(Time.get_ticks_msec() > timeout ): 36 | multiplayer.multiplayer_peer = null; 37 | return; 38 | await get_tree().create_timer(0.5).timeout; 39 | print(multiplayer.get_unique_id(), " connected"); 40 | get_tree().change_scene_to_packed(load("res://Scene1.tscn")); 41 | pass # Replace with function body. 42 | -------------------------------------------------------------------------------- /Lobby.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://dl7g6ss0msbnv"] 2 | 3 | [ext_resource type="Script" path="res://Lobby.gd" id="1_nxqmq"] 4 | 5 | [node name="Menu" type="Control"] 6 | layout_mode = 3 7 | anchors_preset = 15 8 | anchor_right = 1.0 9 | anchor_bottom = 1.0 10 | grow_horizontal = 2 11 | grow_vertical = 2 12 | script = ExtResource("1_nxqmq") 13 | 14 | [node name="HBoxContainer" type="HBoxContainer" parent="."] 15 | layout_mode = 0 16 | offset_right = 40.0 17 | offset_bottom = 40.0 18 | 19 | [node name="Label" type="Label" parent="HBoxContainer"] 20 | layout_mode = 2 21 | text = "Direct Connect" 22 | 23 | [node name="Button" type="Button" parent="HBoxContainer"] 24 | layout_mode = 2 25 | text = "Host" 26 | 27 | [node name="Address" type="LineEdit" parent="HBoxContainer"] 28 | custom_minimum_size = Vector2(125, 0) 29 | layout_mode = 2 30 | placeholder_text = "Address" 31 | 32 | [node name="Port" type="LineEdit" parent="HBoxContainer"] 33 | layout_mode = 2 34 | placeholder_text = "Port" 35 | 36 | [node name="Button2" type="Button" parent="HBoxContainer"] 37 | layout_mode = 2 38 | text = "Connect" 39 | 40 | [connection signal="pressed" from="HBoxContainer/Button" to="." method="_on_host_pressed"] 41 | [connection signal="pressed" from="HBoxContainer/Button2" to="." method="_on_connect_pressed"] 42 | -------------------------------------------------------------------------------- /Player.gd: -------------------------------------------------------------------------------- 1 | class_name Player extends CharacterBody3D 2 | 3 | @export_category("Player") 4 | @export_range(1, 35, 1) var speed: float = 10 # m/s 5 | @export_range(10, 400, 1) var acceleration: float = 100 # m/s^2 6 | 7 | @export_range(0.1, 3.0, 0.1) var jump_height: float = 1 # m 8 | @export_range(0.1, 3.0, 0.1, "or_greater") var camera_sens: float = 1 9 | 10 | var jumping: bool = false 11 | var mouse_captured: bool = false 12 | 13 | var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity") 14 | 15 | var move_dir: Vector2 # Input direction for movement 16 | var look_dir: Vector2 # Input direction for look/aim 17 | 18 | var walk_vel: Vector3 # Walking velocity 19 | var grav_vel: Vector3 # Gravity velocity 20 | var jump_vel: Vector3 # Jumping velocity 21 | 22 | var player_id: int; 23 | var player_controlled := false; 24 | 25 | @onready var camera: Camera3D = $Camera3D 26 | 27 | func _enter_tree(): 28 | $SceneSafeMpSynchronizer.set_multiplayer_authority(player_id); 29 | 30 | func _ready() -> void: 31 | capture_mouse() 32 | if(player_id == multiplayer.get_unique_id()): 33 | $Nose.visible = false; 34 | $Body.visible = false; 35 | $Camera3D.current = true; 36 | player_controlled = true; 37 | print("Player created at: ", position); 38 | 39 | func _unhandled_input(event: InputEvent) -> void: 40 | if !player_controlled: 41 | return; 42 | 43 | if event is InputEventMouseMotion: 44 | look_dir = event.relative * 0.002 45 | if mouse_captured: _rotate_camera() 46 | if Input.is_action_just_pressed("ui_accept"): jumping = true 47 | 48 | func _physics_process(delta: float) -> void: 49 | if !player_controlled: 50 | return; 51 | 52 | if mouse_captured: _handle_joypad_camera_rotation(delta) 53 | velocity = _walk(delta) + _gravity(delta) + _jump(delta) 54 | move_and_slide() 55 | 56 | if position.y < -20: 57 | position = Vector3.ZERO; 58 | 59 | func capture_mouse() -> void: 60 | Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) 61 | mouse_captured = true 62 | 63 | func release_mouse() -> void: 64 | Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) 65 | mouse_captured = false 66 | 67 | func _rotate_camera(sens_mod: float = 1.0) -> void: 68 | rotation.y -= look_dir.x * camera_sens * sens_mod 69 | rotation.x = clamp(rotation.x - look_dir.y * camera_sens * sens_mod, -1.5, 1.5) 70 | 71 | func _handle_joypad_camera_rotation(delta: float, sens_mod: float = 1.0) -> void: 72 | var joypad_dir: Vector2 = Input.get_vector("ui_down","ui_right","ui_left","ui_down") 73 | if joypad_dir.length() > 0: 74 | look_dir += joypad_dir * delta 75 | _rotate_camera(sens_mod) 76 | look_dir = Vector2.ZERO 77 | 78 | func _walk(delta: float) -> Vector3: 79 | move_dir = Input.get_vector("strafe_left", "strafe_right", "move_forward", "move_backward") 80 | var _forward: Vector3 = camera.global_transform.basis * Vector3(move_dir.x, 0, move_dir.y) 81 | var walk_dir: Vector3 = Vector3(_forward.x, 0, _forward.z).normalized() 82 | walk_vel = walk_vel.move_toward(walk_dir * speed * move_dir.length(), acceleration * delta) 83 | return walk_vel 84 | 85 | func _gravity(delta: float) -> Vector3: 86 | grav_vel = Vector3.ZERO if is_on_floor() else grav_vel.move_toward(Vector3(0, velocity.y - gravity, 0), gravity * delta) 87 | return grav_vel 88 | 89 | func _jump(delta: float) -> Vector3: 90 | if jumping: 91 | if is_on_floor(): jump_vel = Vector3(0, sqrt(4 * jump_height * gravity), 0) 92 | jumping = false 93 | return jump_vel 94 | jump_vel = Vector3.ZERO if is_on_floor() else jump_vel.move_toward(Vector3.ZERO, gravity * delta) 95 | return jump_vel 96 | -------------------------------------------------------------------------------- /Player.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=8 format=3 uid="uid://dlda36olggorl"] 2 | 3 | [ext_resource type="Script" path="res://Player.gd" id="1_xohan"] 4 | [ext_resource type="Script" path="res://addons/scene_safe_multiplayer/scene_safe_mp_synchronizer.gd" id="2_ubkok"] 5 | 6 | [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_a2gwc"] 7 | properties/0/path = NodePath(".:position") 8 | properties/0/spawn = true 9 | properties/0/sync = true 10 | properties/0/watch = false 11 | properties/1/path = NodePath(".:rotation") 12 | properties/1/spawn = true 13 | properties/1/sync = true 14 | properties/1/watch = false 15 | 16 | [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_cdijk"] 17 | properties/0/path = NodePath(".:position") 18 | properties/0/spawn = true 19 | properties/0/sync = false 20 | properties/0/watch = false 21 | 22 | [sub_resource type="CapsuleMesh" id="CapsuleMesh_1j1it"] 23 | 24 | [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_nujsa"] 25 | 26 | [sub_resource type="CapsuleMesh" id="CapsuleMesh_end6v"] 27 | radius = 0.25 28 | height = 0.5 29 | 30 | [node name="Player" type="CharacterBody3D"] 31 | script = ExtResource("1_xohan") 32 | 33 | [node name="SceneSafeMpSynchronizer" type="MultiplayerSynchronizer" parent="."] 34 | replication_config = SubResource("SceneReplicationConfig_a2gwc") 35 | script = ExtResource("2_ubkok") 36 | 37 | [node name="SharedAuthoritySynchronizer" type="MultiplayerSynchronizer" parent="."] 38 | replication_config = SubResource("SceneReplicationConfig_cdijk") 39 | script = ExtResource("2_ubkok") 40 | is_spawner_visibility_controller = true 41 | 42 | [node name="Body" type="MeshInstance3D" parent="."] 43 | transform = Transform3D(-4.37114e-08, 0, 1, 0, 1, 0, -1, 0, -4.37114e-08, 0.0723496, 0.962902, 0.117685) 44 | mesh = SubResource("CapsuleMesh_1j1it") 45 | 46 | [node name="CollisionShape3D" type="CollisionShape3D" parent="."] 47 | transform = Transform3D(-4.37114e-08, 0, 1, 0, 1, 0, -1, 0, -4.37114e-08, 0.0723496, 0.959209, 0.117685) 48 | shape = SubResource("CapsuleShape3D_nujsa") 49 | 50 | [node name="Nose" type="MeshInstance3D" parent="."] 51 | transform = Transform3D(-1.74917e-08, 0, 1, 0, 0.626481, 0, -0.400164, 0, -4.37114e-08, 0.0697791, 1.36423, -0.355626) 52 | mesh = SubResource("CapsuleMesh_end6v") 53 | 54 | [node name="Camera3D" type="Camera3D" parent="."] 55 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0723496, 1.55938, 0.117685) 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Godot Scene Safe Multiplayer 2 | A collection of high level multiplayer nodes that handshake between the authority and remote peers to prevent sending data to peers that are not yet ready to receive data. 3 | 4 | ## Purpose 5 | To improve the reliability and **eventual consistency** of scene transitions when using the high-level multiplayer nodes MultiplayerSpawner and MultiplayerSynchronizer. 6 | 7 | When you call `get_tree().change_scene_to_packed(...)` there is no guarantee what order your peers will arrive on the new scene. There's no way to know which ones are running on faster or slower machines, and may take more or less time to load into the scene. There exists a possibility that a spawner or synchronizer will attempt to send a spawn event or a synchronizer will attempt to begin replication when a remote peer isn't ready to receive the data. This results in the dreaded `Node not found` errors, and any clients who missed the events will never get them again, unless you write handshaking code. Or you can lean on these nodes which handle that handshaking for you. 8 | 9 | This plugin cannot completely prevent these `Node not found` errors, as there will always be a possibility that a packet is already in flight destined for a peer that has moved to a new scene, that's just the nature of networks. However, the handshaking process used here **does guarantee** that if and when the peer returns to the scene, it will receive spawn and synchronizer updates again. 10 | 11 | ## Installation 12 | The installation is as easy as it gets - until I can get this added to the Godot Asset Library. Then it will be even easier. 13 | 14 | Just put the `addons/scene_safe_multiplayer` folder into your Godot project, and enable the plugin in your project settings menu: 15 | 16 | ![image](https://github.com/TestSubject06/GodotSceneSafeMultiplayer/assets/597840/5d41b862-0d17-4800-a0ce-e03efdfcb6dc) 17 | 18 | 19 | ## Usage Recipes 20 | 21 | ### I want to spawn player controlled entities 22 | _You can refer to the examples in this repository as you go along._ 23 | 24 | First, create a SceneSafeMpSpawner, just add a new node and search for it - it should show up in the list: 25 | 26 | ![image](https://github.com/TestSubject06/GodotSceneSafeMultiplayer/assets/597840/49ed0345-b164-4dfa-b1fa-effc39079b6f) 27 | 28 | Our test scene is very small and simple - it's just a spawner, a plank to stand on, and a bucket to place our players into: 29 | 30 | ![image](https://github.com/TestSubject06/GodotSceneSafeMultiplayer/assets/597840/33b8b3ca-d98d-4170-a67d-3daa1043fcfc) 31 | 32 | Our spawner won't do much on its own, so you'll need a scene with at least one SceneSafeMpSynchronizer in it for the spawner to actually spawn. Creating one is the same as creating the spawner. We need at least one because we have to have at least one synchronizer on a spawned scene that has the `is_spawner_visibility_controller` property set to true, and has the same authority as the spawner. In our example, we create two - an _almost_ empty one to serve as the visibility controller for the spawner, and one that our players own to synchronize their position and rotation. 33 | 34 | ![image](https://github.com/TestSubject06/GodotSceneSafeMultiplayer/assets/597840/ac7f8014-9a9b-481e-9667-ce9a446d2c29) 35 | 36 | The authority owned synchronizer must synchronize something - anything, even if the spawn and sync checkboxes are both disabled. It just has to have something in the replication panel or Godot won't process it, and the plugin won't work. 37 | 38 | Now that we have a scene with a spawner and a spawnable scene with a synchronizer set up we need to attach to the SceneSafeMpSpawner's signals to let us know when peers are ready to receive spawns: 39 | ``` 40 | extends Node3D 41 | 42 | @onready var spawner: SceneSafeMpSpawner = $SceneSafeMpSpawner as SceneSafeMpSpawner; 43 | 44 | func _ready(): 45 | spawner.spawn_function = player_spawner; 46 | 47 | if is_multiplayer_authority(): 48 | spawner.peer_ready.connect(spawn_player); 49 | spawner.peer_removed.connect(remove_player); 50 | spawner.flush_missed_signals(); 51 | multiplayer.peer_disconnected.connect(remove_player); 52 | 53 | 54 | func spawn_player(peer_id: int): 55 | var spawn_data = {"id": peer_id}; 56 | $SceneSafeMpSpawner.spawn(spawn_data); 57 | 58 | 59 | func remove_player(peer_id: int): 60 | if $Multiplayer.has_node(str(peer_id)): 61 | $Multiplayer.get_node(str(peer_id)).queue_free(); 62 | ``` 63 | 64 | We call `spawner.flush_missed_signals()` because the scene's `_ready()` function is executed _after_ the SceneSafeMpSpawner's `_ready()` function. This means that if the multiplayer authority is itself a player in the game, the `peer_ready` signal would have been emitted before the signal was connected in the scene, so the spawner keeps track of signals that would have been emitted when there were no listeners and saves them for later. The SceneSafeMultiplayer autoload handles the rest for us: 65 | 66 | 1. The multiplayer peer confirms the existence of the spawner. 67 | 2. The authority spawns the player's scene locally, including the two synchronizers. 68 | 3. The spawned scene's synchronizer with `is_spawner_visibility_controller` set to true links with the parent spawner. 69 | 4. At the same time, the synchronizer that the peer owns is registered and a notification from the authority is sent to the peer for later. 70 | 5. When the synchronizer links with the spawner, it sees that there is a confirmed peer on the other end for the authority owned spawner. 71 | 6. The visibility controller synchronizer enables visibility for the confirmed peer, which allows the underlying MultiplayerSpawner to replicate the instance to the peer. 72 | 7. The peer receives their spawned scene and registers the two synchronizers. 73 | 8. The synchronizer owned by the peer picks up on the waiting notification from the spawner authority that the synchronizer on the other end is ready and waiting. 74 | 9. The visibility is enabled from the peer to the multiplayer authority. 75 | 10. All other ready peers simultaneously receive a copy of the spawn and similarly notify the synchronizer's owner that they're ready to receive data. 76 | 77 | Note however that there are two signals attached to the `remove_player` function - `multiplayer.peer_disconnected` and `spawner.peer_removed`. The spawner's `peer_removed` signal does not account for the peer exiting the game abruptly, it only accounts for the spawner calling the `_exit_tree()` lifecycle method. You still need to handle `peer_disconnected` to remove the player in the event that the player suddenly disconnects. And in doing so, you must also take care to not to try and delete entities that don't exist - as `peer_disconnected` doesn't care where the peer was when it disconnected. It may not have been ready in the first place. 78 | 79 | ## What this plugin can't do 80 | These nodes won't allow you to have sets of players freely moving around and existing in different scenes. While these handshakes _could_ allow that, there's a huge amount of work that would have to be done on top of these nodes to handle re-assigning authority of a scene if the current authority leaves, and cleanup if everyone leaves. The example project here allows each peer (including the host/authority) to freely move between scenes - but peers without an authority present are essentially suspended in a void until the authority arrives to provide them with their player spawns. The name of the game here is _eventual consistency_. 81 | 82 | ## FAQ 83 | 84 | ### Why am I getting errors printed to the console when peers switch scenes? 85 | 86 | When a peer changes scenes, like in the example project in this repository, that peer will receive falling-edge errors: `on_despawn_receive ...` and `Node not found root/.../SceneSafeMpSynchronizer`. This is normal and expected behavior. Essentialy what happens is the peer removes the associated synchronizer(s), spawner(s), and spawned scenes - while at the same time emitting an RPC that it has done so. However, there will still be in-flight packets destined for those removed synchronizers resulting in the `Node not found` errors. 87 | 88 | Additionally, when the authority of a spawner receives the notification that a peer has broken the handshake the authority will remove the peer from the _handshake-based_ visibility list, which will try to trigger a despawn on the remote peer who just broke the handshake. This is normal behavior from the underlying `MultiplayerSpawner` instance, but the peer on the other end doesn't have the node anymore. This results in the `on_despawn_receive` error. 89 | 90 | Neither of these errors are harmful to the running of the game, they're no-ops and just there to inform you that something _unusual_ happened - from the perspective of the underlying spawner and synchronizer nodes. If the peer returns to the original scene and handshakes that it's ready - the visibility will be restored and the peer will receive both the spawned scene and any new synchronizer events. 91 | 92 | Without the handshaking in this plugin these failures would be leading edge errors that would prevent future packets from flowing if the visibility wasn't managed otherwise. 93 | 94 | # API Documentation 95 | 96 | ## SceneSafeMpSpawner 97 | 98 | ### Signals 99 | 100 | `peer_ready ( peer_id: int )` 101 | 102 | This signal is only emitted on the **authority** when a peer has confirmed this spawner has been added to the scene. This signal is emitted with one piece of data: an `int` representing the id of the peer that has confirmed the handhake of the associated spawner. This signal does emit for the authority itself, and does so immediately. 103 | 104 | It is possible to receive a spawn signal for a spawner that the authority no longer has, for example if the remote peers are split between two scenes, and a new peer joins a scene that the authority is no longer present in. A bit contrived, and definitely not generally supported, but possible. 105 | 106 | `peer_removed ( peer_id: int )` 107 | 108 | This signal is only emitted on the **authority** when a peer has removed this spawner from their node tree. For example, by transitioning scenes. This is **not emitted** when a peer is disconnected, only when the handhake for the associated spawner is intentionally broken by the peer. This signal is emitted with one piece of data: an `int` representing the id of the peer that has confirmed the removal of the associated spawner. This signal does emit for the authority itself, but it's uninteresting if the reason it's being emitted is a scene transition - rather than a spawner cleanup. 109 | 110 | Like the `peer_ready` signal, it is possible to receive an emission for a spawner that is no longer present on the authority. 111 | 112 | ### Method Descriptions 113 | 114 | `void flush_missed_signals( )` 115 | 116 | When a `SceneSafeMpSpawner` is registered in an authority's scene tree, the `SceneSafeMultiplayer` singleton instructs it to emit ready signals for any peers that are already confirmed. This is sometimes done before the scene itself has become `_ready`, in which case the signals are not completely missed - but are instead stored until the signals are connected later. Once you've connected the signals, call this function to flush out any signals that may have been missed by the authority while building out the scene tree. 117 | 118 | ## SceneSafeMpSynchronizer 119 | 120 | ### Property Descriptions 121 | 122 | `bool is_spawner_visibility_controller = false` 123 | 124 | Controls whether this synchronizer is used to share authority with, and control spawns from a `SceneSafeMpSpawner` node. If true, then this synchronizer's visibility will be controlled by the authority to manage node spawns between connected peers. One, and only one, synchronizer **MUST** be marked as a spawner visibility controller in each spawnable scene, or the authority will not be able to send spawned entities to remote peers. 125 | 126 | `bool public_visibility = false` 127 | 128 | You **MUST NOT** use the `public_visibility` property directly. This will break the handshaking process and undermine all benefits gained from this plugin. Use the `set_public_visibility` method instead. 129 | 130 | ### Method Descriptions 131 | 132 | `void set_public_visibility ( visible: bool )` 133 | 134 | We **CANNOT** use the `public_visibility` property directly, because the `SceneSafeMpSynchronizer` handles two separate streams of visibility: _handshake-based_ visibility and _intentional_ visibility. These two visibility streams are automatically managed and combined into one and sent to the underlying `MultiplayerSynchronizer` instance. The _handshake-based_ visibility is managed by the `SceneSafeMultiplayer` singleton. 135 | 136 | `void set_visibility_for ( peer_id: int, visible: bool )` 137 | 138 | This is an overridden native method to set the _intentional_ visibility for a specific peer. This is changed to ensure it works correctly with the _handshake-based_ visibility filtering as well. You **MUST** call this when referencing a cast `SceneSafeMpSynchronizer` to ensure the correct version is called: `($SceneSafeMpSynchronizer as SceneSafeMpSynchronizer).set_visibility_for( ... )`. 139 | 140 | ## SceneSafeMultiplayerManager 141 | 142 | This autoload exists as a shared data storage and stable RPC recipient for the two nodes. It should not be directly interacted with, unless you're modifying it for your own purposes. 143 | -------------------------------------------------------------------------------- /Scene1.gd: -------------------------------------------------------------------------------- 1 | extends Node3D 2 | 3 | @onready var spawner: SceneSafeMpSpawner = $SceneSafeMpSpawner as SceneSafeMpSpawner; 4 | 5 | # Called when the node enters the scene tree for the first time. 6 | func _ready(): 7 | spawner.spawn_function = player_spawner; 8 | 9 | if is_multiplayer_authority(): 10 | spawner.peer_ready.connect(spawn_player); 11 | spawner.peer_removed.connect(remove_player); 12 | spawner.flush_missed_signals(); 13 | multiplayer.peer_disconnected.connect(remove_player); 14 | 15 | 16 | # Called every frame. 'delta' is the elapsed time since the previous frame. 17 | func _process(_delta): 18 | if(Input.is_action_pressed("exit")): 19 | get_tree().quit(); 20 | if(Input.is_action_pressed("load_scene_2")): 21 | get_tree().change_scene_to_packed(load("res://Scene2.tscn")); 22 | 23 | 24 | func spawn_player(peer_id: int): 25 | var spawn_data = {"id": peer_id}; 26 | $SceneSafeMpSpawner.spawn(spawn_data); 27 | 28 | 29 | func remove_player(peer_id: int): 30 | if $Multiplayer.has_node(str(peer_id)): 31 | $Multiplayer.get_node(str(peer_id)).queue_free(); 32 | 33 | func player_spawner(data: Dictionary): 34 | print("Spawning player: ", data.id); 35 | var player = preload("res://Player.tscn").instantiate(); 36 | player.position = $PlayerSpawn.position; 37 | player.name = str(data.id); 38 | player.player_id = data.id; 39 | 40 | print("Player created at: ", player.position); 41 | 42 | return player; 43 | -------------------------------------------------------------------------------- /Scene1.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=8 format=3 uid="uid://ey3th803jbuc"] 2 | 3 | [ext_resource type="Script" path="res://Scene1.gd" id="1_xnc1b"] 4 | [ext_resource type="Script" path="res://addons/scene_safe_multiplayer/scene_safe_mp_spawner.gd" id="2_436sx"] 5 | 6 | [sub_resource type="BoxShape3D" id="BoxShape3D_lf518"] 7 | size = Vector3(15.8881, 0.236848, 15.9364) 8 | 9 | [sub_resource type="BoxMesh" id="BoxMesh_t0sbg"] 10 | 11 | [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_8motr"] 12 | 13 | [sub_resource type="Sky" id="Sky_h22rw"] 14 | sky_material = SubResource("ProceduralSkyMaterial_8motr") 15 | 16 | [sub_resource type="Environment" id="Environment_kyvgl"] 17 | background_mode = 2 18 | sky = SubResource("Sky_h22rw") 19 | 20 | [node name="Scene1" type="Node3D"] 21 | script = ExtResource("1_xnc1b") 22 | 23 | [node name="SceneSafeMpSpawner" type="MultiplayerSpawner" parent="."] 24 | _spawnable_scenes = PackedStringArray("res://Player.tscn") 25 | spawn_path = NodePath("../Multiplayer") 26 | script = ExtResource("2_436sx") 27 | 28 | [node name="Multiplayer" type="Node" parent="."] 29 | 30 | [node name="StaticBody3D" type="StaticBody3D" parent="."] 31 | 32 | [node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"] 33 | shape = SubResource("BoxShape3D_lf518") 34 | 35 | [node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D"] 36 | transform = Transform3D(15.8965, 0, 0, 0, 0.227115, 0, 0, 0, 15.716, 0, 0, 0) 37 | mesh = SubResource("BoxMesh_t0sbg") 38 | skeleton = NodePath("../..") 39 | 40 | [node name="PlayerSpawn" type="Node3D" parent="."] 41 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 7.1944, 0.875969) 42 | 43 | [node name="WorldEnvironment" type="WorldEnvironment" parent="."] 44 | environment = SubResource("Environment_kyvgl") 45 | 46 | [node name="DirectionalLight3D" type="DirectionalLight3D" parent="WorldEnvironment"] 47 | transform = Transform3D(1, 0, 0, 0, 0.371659, 0.928369, 0, -0.928369, 0.371659, 0, 3.15055, 0) 48 | -------------------------------------------------------------------------------- /Scene2.gd: -------------------------------------------------------------------------------- 1 | extends Node3D 2 | 3 | @onready var spawner: SceneSafeMpSpawner = $SceneSafeMpSpawner as SceneSafeMpSpawner; 4 | 5 | # Called when the node enters the scene tree for the first time. 6 | func _ready(): 7 | spawner.spawn_function = player_spawner; 8 | 9 | if is_multiplayer_authority(): 10 | spawner.peer_ready.connect(spawn_player); 11 | spawner.peer_removed.connect(remove_player); 12 | spawner.flush_missed_signals(); 13 | multiplayer.peer_disconnected.connect(remove_player); 14 | 15 | 16 | # Called every frame. 'delta' is the elapsed time since the previous frame. 17 | func _process(_delta): 18 | if(Input.is_action_pressed("exit")): 19 | get_tree().quit(); 20 | if(Input.is_action_pressed("load_scene_1")): 21 | get_tree().change_scene_to_packed(load("res://Scene1.tscn")); 22 | 23 | 24 | func spawn_player(peer_id: int): 25 | var spawn_data = {"id": peer_id}; 26 | $SceneSafeMpSpawner.spawn(spawn_data); 27 | 28 | 29 | func remove_player(peer_id: int): 30 | if $Multiplayer.has_node(str(peer_id)): 31 | $Multiplayer.get_node(str(peer_id)).queue_free(); 32 | 33 | func player_spawner(data: Dictionary): 34 | print("Spawning player: ", data.id); 35 | var player = preload("res://Player.tscn").instantiate(); 36 | player.position = $PlayerSpawn.position; 37 | player.name = str(data.id); 38 | player.player_id = data.id; 39 | 40 | print("Player created at: ", player.position); 41 | 42 | return player; 43 | -------------------------------------------------------------------------------- /Scene2.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=10 format=3 uid="uid://cjkw132lbhjv6"] 2 | 3 | [ext_resource type="Script" path="res://Scene2.gd" id="1_xxth8"] 4 | [ext_resource type="Script" path="res://addons/scene_safe_multiplayer/scene_safe_mp_spawner.gd" id="2_jbmea"] 5 | 6 | [sub_resource type="BoxShape3D" id="BoxShape3D_lf518"] 7 | size = Vector3(15.8881, 0.236848, 15.9364) 8 | 9 | [sub_resource type="BoxMesh" id="BoxMesh_t0sbg"] 10 | 11 | [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_8motr"] 12 | 13 | [sub_resource type="Sky" id="Sky_h22rw"] 14 | sky_material = SubResource("ProceduralSkyMaterial_8motr") 15 | 16 | [sub_resource type="Environment" id="Environment_kyvgl"] 17 | background_mode = 2 18 | sky = SubResource("Sky_h22rw") 19 | 20 | [sub_resource type="CylinderMesh" id="CylinderMesh_u7arf"] 21 | 22 | [sub_resource type="CylinderShape3D" id="CylinderShape3D_7dr2p"] 23 | 24 | [node name="Scene2" type="Node3D"] 25 | script = ExtResource("1_xxth8") 26 | 27 | [node name="SceneSafeMpSpawner" type="MultiplayerSpawner" parent="."] 28 | _spawnable_scenes = PackedStringArray("res://Player.tscn") 29 | spawn_path = NodePath("../Multiplayer") 30 | script = ExtResource("2_jbmea") 31 | 32 | [node name="Multiplayer" type="Node" parent="."] 33 | 34 | [node name="StaticBody3D" type="StaticBody3D" parent="."] 35 | 36 | [node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"] 37 | shape = SubResource("BoxShape3D_lf518") 38 | 39 | [node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D"] 40 | transform = Transform3D(15.8965, 0, 0, 0, 0.227115, 0, 0, 0, 15.716, 0, 0, 0) 41 | mesh = SubResource("BoxMesh_t0sbg") 42 | skeleton = NodePath("../..") 43 | 44 | [node name="PlayerSpawn" type="Node3D" parent="."] 45 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.79717, 0.875969) 46 | 47 | [node name="WorldEnvironment" type="WorldEnvironment" parent="."] 48 | environment = SubResource("Environment_kyvgl") 49 | 50 | [node name="DirectionalLight3D" type="DirectionalLight3D" parent="WorldEnvironment"] 51 | transform = Transform3D(1, 0, 0, 0, 0.371659, 0.928369, 0, -0.928369, 0.371659, 0, 3.15055, 0) 52 | 53 | [node name="StaticBody3D2" type="StaticBody3D" parent="."] 54 | 55 | [node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D2"] 56 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.24865, 1.10821, 6.49735) 57 | mesh = SubResource("CylinderMesh_u7arf") 58 | 59 | [node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D2"] 60 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.52003, 1.02549, 6.41142) 61 | shape = SubResource("CylinderShape3D_7dr2p") 62 | -------------------------------------------------------------------------------- /SceneSafeMultiplayerIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TestSubject06/GodotSceneSafeMultiplayer/5897cef4cf808645ebc3554d0ac9f4d468bbe061/SceneSafeMultiplayerIcon.png -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 TestSubject06 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/MultiplayerSpawner.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/MultiplayerSpawner.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bkx8euy8lml67" 6 | path="res://.godot/imported/MultiplayerSpawner.svg-9528303ef93fe35b442e599ff3cea031.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/scene_safe_multiplayer/MultiplayerSpawner.svg" 14 | dest_files=["res://.godot/imported/MultiplayerSpawner.svg-9528303ef93fe35b442e599ff3cea031.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/MultiplayerSynchronizer.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/MultiplayerSynchronizer.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bnmajbony6fdj" 6 | path="res://.godot/imported/MultiplayerSynchronizer.svg-2db72bec702e4cd9309138b69007d3e7.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/scene_safe_multiplayer/MultiplayerSynchronizer.svg" 14 | dest_files=["res://.godot/imported/MultiplayerSynchronizer.svg-2db72bec702e4cd9309138b69007d3e7.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="SceneSafeMultiplayer" 4 | description="A collection of high level multiplayer nodes that handshake between the authority and remote peers to prevent sending data to peers that are not yet ready to receive data." 5 | author="TestSubject06" 6 | version="0.2.0" 7 | script="register_plugin.gd" 8 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/register_plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | 5 | func _enter_tree(): 6 | add_custom_type( 7 | "SceneSafeMpSpawner", 8 | "MultiplayerSpawner", 9 | preload("res://addons/scene_safe_multiplayer/scene_safe_mp_spawner.gd"), 10 | preload("res://addons/scene_safe_multiplayer/MultiplayerSpawner.svg") 11 | ); 12 | add_custom_type( 13 | "SceneSafeMpSynchronizer", 14 | "MultiplayerSynchronizer", 15 | preload("res://addons/scene_safe_multiplayer/scene_safe_mp_synchronizer.gd"), 16 | preload("res://addons/scene_safe_multiplayer/MultiplayerSynchronizer.svg") 17 | ); 18 | add_autoload_singleton( 19 | "SceneSafeMultiplayer", 20 | "res://addons/scene_safe_multiplayer/scene_safe_mp_manager.gd" 21 | ); 22 | 23 | 24 | func _exit_tree(): 25 | remove_custom_type("SceneSafeMpSpawner"); 26 | remove_custom_type("SceneSafeMpSynchronizer"); 27 | remove_autoload_singleton("SceneSafeMultiplayer"); 28 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/scene_safe_mp_manager.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | class_name SceneSafeMpManager 3 | ## This is a manager class designed to live as an autoloaded script with the name 4 | ## `SceneSafeMultiplayer`. Everything that happens in here is automatically handled by the bundled 5 | ## SceneSafeMpSpawner and SceneSafeMpSynchronizer nodes. You are not intended to directly interface 6 | ## with anything in this class, do so at your own risk. 7 | 8 | 9 | ## Stores a collection of node paths, and which peers have confirmed that they contain said nodes. 10 | ## The map has the following shape: 11 | ## { 12 | ## [node_path]: { 13 | ## "authority": int, 14 | ## "confirmed_peers": Array[int], 15 | ## "linked_synchronizers": Array[SceneSafeMpSynchronizer], 16 | ## } 17 | ## } 18 | var spawner_map = {}; 19 | 20 | 21 | ## Stores a collection of node paths, and which peers have confirmed that they contain said nodes. 22 | ## The map has the following shape: 23 | ## { 24 | ## [node_path]: { 25 | ## "authority": int, 26 | ## "confirmed_peers": Array[int], 27 | ## } 28 | ## } 29 | var synchronizer_map = {}; 30 | 31 | 32 | ## We need to ensure that whenever a peer disconnects completely that we clean up any references 33 | ## in all of the maps, especially from any linked_synchronizers. 34 | func _ready(): 35 | multiplayer.peer_disconnected.connect(_cleanup_peer_data); 36 | multiplayer.server_disconnected.connect(cleanup_all_data); 37 | 38 | 39 | ## This method is only intended to be called by a SceneSafeMpSpawner that has entered the tree. 40 | ## It sets up some handshake data, and RPCs to the authority to confirm the existence of the spawner. 41 | ## If running on the authority, it checks for existing & waiting peers and emits spawns for them all. 42 | func register_spawner(node_name: String, id: int, authority_id: int) -> void: 43 | if not spawner_map.has(node_name): 44 | spawner_map[node_name] = { 45 | "authority": authority_id, 46 | "confirmed_peers": [], 47 | "linked_synchronizers": [], 48 | }; 49 | 50 | var spawner_entry = spawner_map[node_name]; 51 | 52 | spawner_entry.confirmed_peers.push_back(id); 53 | if multiplayer.get_unique_id() == authority_id: 54 | if(get_tree().current_scene.has_node(node_name)): 55 | if id == authority_id and spawner_entry.confirmed_peers.size() > 1: 56 | # There are peers here waiting for their spawns... 57 | for peer in spawner_entry.confirmed_peers: 58 | get_tree().current_scene.get_node(node_name).activate_ready_singal(peer); 59 | else: 60 | get_tree().current_scene.get_node(node_name).activate_ready_singal(id); 61 | 62 | spawner_entry.linked_synchronizers = spawner_entry.linked_synchronizers.filter( 63 | func(sync): return is_instance_valid(sync) 64 | ); 65 | 66 | if spawner_entry.linked_synchronizers.size(): 67 | for sync in spawner_entry.linked_synchronizers: 68 | if is_instance_valid(sync): 69 | sync.enable_data_flow_for([id]); 70 | else: 71 | peer_confirmed_spawner.rpc_id(authority_id, node_name, id, authority_id); 72 | 73 | 74 | ## This method is only intended to be called by a SceneSafeMpSpawner that has exited the tree. 75 | func unregister_spawner(node_name: String, id: int, authority_id: int) -> void: 76 | if ( 77 | not spawner_map.has(node_name) 78 | or (spawner_map.has(node_name) and not spawner_map[node_name].confirmed_peers.has(id)) 79 | ): 80 | return; 81 | 82 | spawner_map[node_name].confirmed_peers.erase(id); 83 | 84 | if spawner_map[node_name].confirmed_peers.size() == 0: 85 | spawner_map.erase(node_name); 86 | 87 | if authority_id != multiplayer.get_unique_id(): 88 | peer_unregistered_spawner.rpc_id(authority_id, node_name, id, authority_id); 89 | elif(authority_id == multiplayer.get_unique_id() && get_tree().current_scene.has_node(node_name)): 90 | get_tree().current_scene.get_node(node_name).activate_removed_signal(id); 91 | 92 | 93 | ## This method is only intended to be called by a SceneSafeMpSynchronizer that has entered the tree. 94 | ## Notifies the authority of the synchronizer that it's ready. If running as the authority, it 95 | ## enables the handshake-based data flow between the peers. 96 | func register_synchronizer(node_name: String, id: int, authority_id: int) -> void: 97 | if not synchronizer_map.has(node_name): 98 | synchronizer_map[node_name] = { "authority": authority_id, "confirmed_peers": [] }; 99 | 100 | synchronizer_map[node_name].confirmed_peers.push_back(id); 101 | 102 | # Dear GDScript style guide maintainers... this looks terrible. 103 | if ( 104 | multiplayer.get_unique_id() == authority_id 105 | and synchronizer_map[node_name].confirmed_peers.has(authority_id) 106 | ): 107 | get_tree().current_scene.get_node(node_name).enable_data_flow_for( 108 | synchronizer_map[node_name].confirmed_peers 109 | ); 110 | elif multiplayer.get_unique_id() != authority_id: 111 | peer_confirmed_synchronizer.rpc_id(authority_id, node_name, id, authority_id); 112 | 113 | 114 | ## This method is only intended to be called by a SceneSafeMpSynchronizer that has exited the tree. 115 | ## It disables the handshake-based data flow between the peers. It cannot guarantee that in-fight 116 | ## packets destined for the existing synchronizer won't arrive and attempt to be processed, resulting 117 | ## in the "Node not found" error being thrown. However, by removing the visibility and confirming it 118 | ## on the other side, the synchronizer cache is cleared and data flow can be resumed later without 119 | ## needing to fully reconnect if the handshake is ever reconfirmed in the future. 120 | func unregister_synchronizer(node_name: String, id: int, authority_id: int) -> void: 121 | if not synchronizer_map.has(node_name): 122 | return; 123 | 124 | synchronizer_map[node_name].confirmed_peers.erase(id); 125 | 126 | if synchronizer_map[node_name].confirmed_peers.size() == 0: 127 | synchronizer_map.erase(node_name); 128 | 129 | if multiplayer.get_unique_id() == authority_id and get_tree().current_scene.has_node(node_name): 130 | get_tree().current_scene.get_node(node_name).disable_data_flow_for(id); 131 | elif multiplayer.get_unique_id() != authority_id: 132 | peer_unregistered_synchronizer.rpc_id(authority_id, node_name, id, authority_id); 133 | 134 | 135 | ## This stores a visibility synchronizer associated with a SceneSafeMpSpawner with the spawner's 136 | ## disctionary data on the authority's machine. This allows the spawner to send spawned entities 137 | ## over the wire to confirmed peers. 138 | func link_visibility_sync_to_spawner( 139 | spawner_path: String, 140 | synchronizer: SceneSafeMpSynchronizer 141 | ) -> void: 142 | if spawner_map.has(spawner_path): 143 | spawner_map[spawner_path].linked_synchronizers.push_back(synchronizer); 144 | 145 | synchronizer.enable_data_flow_for(spawner_map[spawner_path].confirmed_peers); 146 | 147 | 148 | ## Cleans up the visibility synchronizer data in case a peer leaves the scene. 149 | func unlink_visibility_sync_from_spawner( 150 | spawner_path: String, 151 | synchronizer: SceneSafeMpSynchronizer 152 | ) -> void: 153 | if spawner_map.has(spawner_path): 154 | spawner_map[spawner_path].linked_synchronizers.erase(synchronizer); 155 | 156 | 157 | ## Clean up a disconnected peer's data from all of the confirmed peers lists. 158 | func _cleanup_peer_data(peer: int): 159 | for key in spawner_map: 160 | spawner_map[key].confirmed_peers.erase(peer); 161 | spawner_map[key].linked_synchronizers = spawner_map[key].linked_synchronizers.filter( 162 | func(sync): return is_instance_valid(sync); 163 | ); 164 | if spawner_map[key].confirmed_peers.size() == 0: 165 | spawner_map.erase(key); 166 | 167 | for key in synchronizer_map: 168 | synchronizer_map[key].confirmed_peers.erase(peer); 169 | if synchronizer_map[key].confirmed_peers.size() == 0: 170 | synchronizer_map.erase(key); 171 | 172 | 173 | ## Clean up all data from all of the confirmed peers lists. 174 | func cleanup_all_data(): 175 | spawner_map = {} 176 | 177 | synchronizer_map = {} 178 | 179 | 180 | @rpc("any_peer", "call_local", "reliable") 181 | func peer_confirmed_spawner(node_name: String, id: int, authority_id: int) -> void: 182 | register_spawner(node_name, id, authority_id); 183 | 184 | 185 | @rpc("any_peer", "call_local", "reliable") 186 | func peer_unregistered_spawner(node_name: String, id: int, authority_id: int) -> void: 187 | unregister_spawner(node_name, id, authority_id); 188 | 189 | 190 | @rpc("any_peer", "call_local", "reliable") 191 | func peer_confirmed_synchronizer(node_name: String, id: int, authority_id: int) -> void: 192 | register_synchronizer(node_name, id, authority_id); 193 | 194 | 195 | @rpc("any_peer", "call_local", "reliable") 196 | func peer_unregistered_synchronizer(node_name: String, id: int, authority_id: int) -> void: 197 | unregister_synchronizer(node_name, id, authority_id); 198 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/scene_safe_mp_spawner.gd: -------------------------------------------------------------------------------- 1 | extends MultiplayerSpawner 2 | class_name SceneSafeMpSpawner 3 | ## Handshakes spawnable nodes before allowing them to be spawned on remote peers. 4 | 5 | 6 | ## The authority does not receive spawn entity signal emissions for a peer until the peer has 7 | ## confirmed the existence of the matching spawner. 8 | 9 | ## Associated peers will not receive their copies of the entities spawned by the authority until 10 | ## after the entity is added to the scene tree for the authority, and the remote peers have 11 | ## confirmed the existence of the spawner. This is done by enabling the data flow for an associated 12 | ## SceneSafeMpSynchronizer with the `is_spawner_visibility_controller` property set to true. To 13 | ## ensure proper synchronization, any entity spawned by a SceneSafeMpSpawner MUST have at least one 14 | ## SceneSafeMpSynchronizer with the `is_spawner_visibility_controller` property set to true. 15 | 16 | ## This signal is only emitted on the authority when a peer has 17 | ## confirmed this spawner has been added to the scene. This signal is emitted with two 18 | ## pieces of data: A String representing the node path of the spawner that should emit, and an int 19 | ## representing the id of the peer that has confirmed the handhake of the associated spawner. 20 | ## It is possible to receive a spawn signal for a spawner that the authority no longer owns, for 21 | ## example if the remote peers are split between two scenes, and a new peer joins a scene that the 22 | ## authority is no longer present in. A bit contrived, and definitely not supported, but possible. 23 | signal peer_ready; 24 | 25 | 26 | ## This signal is only emitted on the authority when a peer has 27 | ## removed this spawner from their node tree. For example, by transitioning scenes. This is not emitted 28 | ## when a peer is disconnected, only when the handhake for the associated spawner is intentionally 29 | ## broken by the remote peer. This signal is emitted with two pieces of data: A String representing 30 | ## the node path of the spawner that has been removed, and an int representing the id of the peer 31 | ## that has confirmed the removal of the associated spawner. Like the remote_spawner_ready signal, 32 | ## it is possible to receive an emission for a spawner that is no longer present on the authority. 33 | signal peer_removed; 34 | 35 | 36 | ## Especially with a peer that is also the host, it's possible for a signal to be fired before the 37 | ## host's ready function is called. So the spawner holds missed signals until flush_missed_signals 38 | ## is called. A ready and a remove will cancel out and remove themselves from the missed signals. 39 | var _missed_ready_signals: Array[int] = []; 40 | var _missed_removed_signals: Array[int] = []; 41 | 42 | 43 | ## Register the spawner 44 | func _ready(): 45 | SceneSafeMultiplayer.register_spawner( 46 | get_path(), 47 | multiplayer.get_unique_id(), 48 | get_multiplayer_authority(), 49 | ); 50 | 51 | 52 | ## Unreguster the spawner 53 | func _exit_tree(): 54 | SceneSafeMultiplayer.unregister_spawner( 55 | get_path(), 56 | multiplayer.get_unique_id(), 57 | get_multiplayer_authority(), 58 | ); 59 | 60 | 61 | ## Intended to be called by the SceneSafeManager autoload. Lets this spawner know that a peer 62 | ## is ready to receive their spawn. 63 | func activate_ready_singal(peer: int): 64 | if peer_ready.get_connections().size() == 0: 65 | _missed_ready_signals.push_back(peer); 66 | 67 | # If there was already a removed signal pending, then just clear them both. 68 | if _missed_removed_signals.has(peer): 69 | _missed_ready_signals.erase(peer); 70 | _missed_removed_signals.erase(peer); 71 | else: 72 | peer_ready.emit(peer); 73 | 74 | 75 | ## Intended to be called by the SceneSafeManager autoload. Lets this spawner know that a peer should 76 | ## be removed from consideration. 77 | func activate_removed_signal(peer: int): 78 | if peer_removed.get_connections().size() == 0: 79 | _missed_removed_signals.push_back(peer); 80 | 81 | # If there was already a ready signal pending, just clear them both. 82 | if _missed_ready_signals.has(peer): 83 | _missed_ready_signals.erase(peer); 84 | _missed_removed_signals.erase(peer); 85 | else: 86 | peer_removed.emit(peer); 87 | 88 | 89 | func flush_missed_signals(): 90 | for peer in _missed_ready_signals: 91 | peer_ready.emit(peer); 92 | 93 | for peer in _missed_removed_signals: 94 | peer_removed.emit(peer); 95 | -------------------------------------------------------------------------------- /addons/scene_safe_multiplayer/scene_safe_mp_synchronizer.gd: -------------------------------------------------------------------------------- 1 | extends MultiplayerSynchronizer 2 | class_name SceneSafeMpSynchronizer 3 | ## Handshakes with remote synchronizers before allowing data to flow between remote peers. 4 | 5 | 6 | ## This is an extended version of the MultiplayerSynchronizer that interfaces with the 7 | ## SceneSafeMpManager to ensure that all peers have actually instantiated their respective 8 | ## synchronizers before beginning to send data to them. This prevents dreaded "Node not found" 9 | ## errors from clogging up the data flow and preventing synchronizers from functioning when users 10 | ## change scenes. 11 | 12 | ## NOTE: When using this node, you MUST NOT set the public_visibility property. If you need to 13 | ## change the public visibility after handshaking is completed, you MUST use the 14 | ## set_public_visibility method. Directly setting the property will break this node and the 15 | ## guarantees that the handshaking system provides. 16 | 17 | ## Controls whether this synchronizer is used to share authority with, and control spawns from a 18 | ## SceneSafeMpSpawner node. If true, then this Synchronizer's visibility will be controlled by the 19 | ## authority to manage node spawns between connected peers. One, and only one synchronizer should 20 | ## be marked as a spawner visibility controller 21 | @export var is_spawner_visibility_controller: bool = false; 22 | 23 | ## This is a list of the peers that have confirmed the existence of this synchronizer. 24 | ## These peers are safe to send data to, and only make sense from the perspective of the authority 25 | ## of this synchronizer. 26 | var _internal_handshake_visibility: Array = []; 27 | 28 | ## This is a separate visibility map so that we don't clobber existing visibility entirely, 29 | ## we want to be able to support both at once: Handshake visibility as well as normal visibility. 30 | var _internal_visibility_map: Dictionary = {}; 31 | 32 | ## This is used internally to store whether the synchronizer should be public once the handshaking 33 | ## is completed. 34 | var _internal_public_visibility: bool; 35 | 36 | ## This is used in conjunction with the spawner visibility controller variable to link this 37 | ## synchronizer with the parent spawner that should be controlling it. 38 | var _parent_spawner: SceneSafeMpSpawner; 39 | 40 | ## Note: It is not possible to re-define a public property so we can't do anything like the below 41 | ## to clean up the usage of the public visible property. 42 | #var public_visibility: bool: 43 | # get: 44 | # return __internal_public_visibility; 45 | # set(visibility): 46 | # __internal_public_visibility = visibiity; 47 | 48 | func _ready(): 49 | # Save off the user-intended value of the public_visibility; 50 | _internal_public_visibility = public_visibility; 51 | 52 | # Safe synchronizers are always publicly invisible: this prevents sending data to peers who 53 | # are not ready yet. 54 | public_visibility = false; 55 | 56 | SceneSafeMultiplayer.register_synchronizer( 57 | get_path(), 58 | multiplayer.get_unique_id(), 59 | get_multiplayer_authority(), 60 | ); 61 | 62 | if is_spawner_visibility_controller: 63 | _parent_spawner = _get_parent_spawner(get_parent(), []); 64 | 65 | ## Cleanup 66 | func _exit_tree(): 67 | if is_spawner_visibility_controller and _parent_spawner: 68 | SceneSafeMultiplayer.unlink_visibility_sync_from_spawner(_parent_spawner.get_path(), self); 69 | 70 | SceneSafeMultiplayer.unregister_synchronizer( 71 | get_path(), 72 | multiplayer.get_unique_id(), 73 | get_multiplayer_authority(), 74 | ); 75 | 76 | 77 | # We cannot use the underying public variable directly, as it's necessary for it to always be false 78 | # for scene safety guarantees. 79 | func set_public_visibility(visible: bool): 80 | _internal_public_visibility = visible; 81 | 82 | _update_underlying_visibiity_for_all(); 83 | 84 | 85 | ## Confirms handshake and enables handshake-based visibility for a collection of peers. Often called 86 | ## with a single peer, but there are cases where a group of peers is all enabled at the same time. 87 | func enable_data_flow_for(peers: Array): 88 | for peer in peers: 89 | if _internal_handshake_visibility.has(peer): 90 | continue; 91 | 92 | _internal_handshake_visibility.push_back(peer); 93 | 94 | _update_underlying_visibiity_for(peer); 95 | 96 | 97 | ## Breaks the handshake and disables handshake-based visibility for a specific peer. This does not 98 | ## guarantee that a peer won't receive additional in-flight packets after breaking the handshake. 99 | ## This case is fine, since clearing the visibility will clean the state if said peer becomes 100 | ## enabled again later. 101 | func disable_data_flow_for(peer: int): 102 | if _internal_handshake_visibility.has(peer): 103 | _internal_handshake_visibility.erase(peer); 104 | 105 | _update_underlying_visibiity_for(peer); 106 | 107 | 108 | ## This is an overridden native method to set the visibility for a specific peer. This is changed 109 | ## to ensure it works correctly with the handshake-based visibility filtering as well. You MUST 110 | ## call this when referencing a cast SceneSafeMpSynchronizer to ensure the correct version is called. 111 | @warning_ignore("native_method_override") 112 | func set_visibility_for(peer: int, visible: bool): 113 | if not visible and _internal_visibility_map.has(str(peer)): 114 | _internal_visibility_map.erase(peer); 115 | elif visible and not _internal_visibility_map.has(str(peer)): 116 | _internal_visibility_map[str(peer)] = visible; 117 | 118 | _update_underlying_visibiity_for(peer); 119 | 120 | 121 | ## A recursive method to scan up the node tree to find the SceneSafeMpSpawner that spawned this 122 | ## synchronizer. 123 | func _get_parent_spawner(node: Node, parents_checked: Array[Node]): 124 | for child in node.get_children(): 125 | if child is SceneSafeMpSpawner: 126 | # If it's the right spawner and we own both... 127 | if ( 128 | parents_checked.has(child.get_node(child.spawn_path)) 129 | and child.get_multiplayer_authority() == get_multiplayer_authority() 130 | ): 131 | SceneSafeMultiplayer.link_visibility_sync_to_spawner(child.get_path(), self); 132 | return child; 133 | 134 | if node.get_parent(): 135 | var parents = Array(parents_checked); 136 | parents.push_back(node); 137 | return _get_parent_spawner(node.get_parent(), parents); 138 | else: 139 | return null; 140 | 141 | 142 | ## Merges the hasndshake-based and normal visibility to determine if the peer should be visible 143 | func _update_underlying_visibiity_for(peer: int): 144 | var normal_visibility = _internal_public_visibility; 145 | if _internal_visibility_map.has(str(peer)): 146 | normal_visibility = _internal_visibility_map[str(peer)]; 147 | 148 | if _internal_handshake_visibility.has(peer) and normal_visibility: 149 | super.set_visibility_for(peer, true); 150 | else: 151 | super.set_visibility_for(peer, false); 152 | 153 | 154 | ## The same as the above, but for the superset of all peers in both maps. 155 | func _update_underlying_visibiity_for_all(): 156 | var peers = _internal_handshake_visibility; 157 | for key in _internal_visibility_map: 158 | if not peers.has(int(key)): 159 | peers.push_back(int(key)); 160 | 161 | for peer in peers: 162 | _update_underlying_visibiity_for(peer); 163 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icon.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://b3bk5shnnw4n6" 6 | path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://icon.svg" 14 | dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /project.godot: -------------------------------------------------------------------------------- 1 | ; Engine configuration file. 2 | ; It's best edited using the editor UI and not directly, 3 | ; since the parameters that go here are not all obvious. 4 | ; 5 | ; Format: 6 | ; [section] ; section goes between [] 7 | ; param=value ; assign values to parameters 8 | 9 | config_version=5 10 | 11 | [application] 12 | 13 | config/name="SceneSafeMulti" 14 | run/main_scene="res://Lobby.tscn" 15 | config/features=PackedStringArray("4.1", "Forward Plus") 16 | config/icon="res://icon.svg" 17 | 18 | [autoload] 19 | 20 | SceneSafeMultiplayer="*res://addons/scene_safe_multiplayer/scene_safe_mp_manager.gd" 21 | 22 | [debug] 23 | 24 | gdscript/warnings/native_method_override=1 25 | 26 | [display] 27 | 28 | window/size/viewport_width=720 29 | window/size/viewport_height=480 30 | 31 | [editor_plugins] 32 | 33 | enabled=PackedStringArray("res://addons/scene_safe_multiplayer/plugin.cfg") 34 | 35 | [input] 36 | 37 | move_forward={ 38 | "deadzone": 0.5, 39 | "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) 40 | ] 41 | } 42 | move_backward={ 43 | "deadzone": 0.5, 44 | "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) 45 | ] 46 | } 47 | strafe_left={ 48 | "deadzone": 0.5, 49 | "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) 50 | ] 51 | } 52 | strafe_right={ 53 | "deadzone": 0.5, 54 | "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) 55 | ] 56 | } 57 | exit={ 58 | "deadzone": 0.5, 59 | "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":4194305,"key_label":0,"unicode":0,"echo":false,"script":null) 60 | ] 61 | } 62 | load_scene_1={ 63 | "deadzone": 0.5, 64 | "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":79,"key_label":0,"unicode":111,"echo":false,"script":null) 65 | ] 66 | } 67 | load_scene_2={ 68 | "deadzone": 0.5, 69 | "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":75,"key_label":0,"unicode":107,"echo":false,"script":null) 70 | ] 71 | } 72 | --------------------------------------------------------------------------------