feat(multiplayer): Phase 1 transport, connection, and lobby

Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
This commit is contained in:
Josh Creek
2026-08-20 08:18:59 +01:00
parent e83bb4fa0c
commit 4533da34e0
30 changed files with 2509 additions and 12 deletions
+112
View File
@@ -0,0 +1,112 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
[node name="Lobby" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_lobby")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(520, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 40
text = "Lobby"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
theme_override_font_sizes/font_size = 14
text = "Connecting..."
horizontal_alignment = 1
autowrap_mode = 2
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Team 1"
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Team 2"
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 10
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Switch Team"
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Ready"
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Leave"
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
+96
View File
@@ -100,6 +100,51 @@ custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Play Match"
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Multiplayer"
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "LAN / direct IP — host a match or join one"
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Host"
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
text = "127.0.0.1"
placeholder_text = "IP address"
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
custom_minimum_size = Vector2(96, 40)
layout_mode = 2
text = "Join"
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 0.5, 0.5, 1)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = ""
autowrap_mode = 2
visible = false
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
@@ -180,7 +225,58 @@ custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Watch Match"
[node name="ConnectingOverlay" type="Control" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
[node name="Backdrop" type="ColorRect" parent="ConnectingOverlay"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0.7)
[node name="CenterContainer" type="CenterContainer" parent="ConnectingOverlay"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="ConnectingOverlay/CenterContainer"]
custom_minimum_size = Vector2(360, 0)
layout_mode = 2
theme_override_constants/separation = 14
[node name="ConnectingStatusLabel" type="Label" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Connecting..."
horizontal_alignment = 1
autowrap_mode = 2
[node name="ConnectingCancelButton" type="Button" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Cancel"
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/server_boot.gd" id="1_sb"]
[node name="ServerBoot" type="Node"]
script = ExtResource("1_sb")