M2.7: Host Game, backups, launchd/systemd samples, hosting and Tailscale guide

This commit is contained in:
Paul Bailey
2026-11-04 23:19:00 +00:00
parent 2ab79b68c6
commit ab1f417c32
14 changed files with 723 additions and 11 deletions

View File

@@ -240,6 +240,8 @@ func _on_peer_disconnected(id: int) -> void:
_s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
if _hosted_by(String(p.id)):
_host_left.call_deferred()
players_changed.emit()
func _process(delta: float) -> void:
@@ -255,6 +257,7 @@ func _process(delta: float) -> void:
_send_left = SEND_INTERVAL
_broadcast_snapshot()
return
_host_tick()
if state == "connecting":
_join_left -= delta
if _join_left <= 0.0:
@@ -272,6 +275,135 @@ func _tick_clock(delta: float) -> void:
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
# --- hosting from the title screen (M2.7) ------------------------------------------
# "Host Game" runs a dedicated server as a separate, windowless copy of the
# game on this machine, then joins it like anyone else (so you're its first
# player, and its admin). Its world and settings live in user://host/. Its
# log goes to user://host/server.log. Leaving asks it to save and stop.
signal host_status(text: String, failed: bool)
var host_config := "user://host/server.json" ## (tests use their own)
var hosting := false
var _host_pid := 0
var _host_io: FileAccess
var _host_log: FileAccess
var _host_out := ""
var _host_port := DEFAULT_PORT
## Settings for a hosted game: name, password, port, a rules preset
## (co-op / rivals / adversarial) and the seed for a new island.
const PRESETS := {
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
}
func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void:
if hosting or state != "offline":
return
if _host_pid != 0 and OS.is_process_running(_host_pid):
host_status.emit("The last server is still saving: try again in a moment", true)
return
var c: RefCounted = ServerConfig.open(host_config)
c.set_rule("server_name", server_name)
for k in PRESETS.get(preset, PRESETS["co-op"]):
c.set_rule(k, PRESETS[preset][k])
c.data.password = password
c.data.port = port
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
if fresh:
c.data.seed = seed
c.save()
_host_port = port
var args := PackedStringArray(["--headless"])
if not OS.has_feature("template"):
args.append_array(["--path", ProjectSettings.globalize_path("res://")]) # (running from the editor build)
args.append_array(["--", "--server", "--host-id", String(get_identity().player_id),
"--config", ProjectSettings.globalize_path(host_config), "--exit-with", str(OS.get_process_id())])
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
if p.is_empty():
host_status.emit("Couldn't start the server", true)
return
_host_pid = int(p.pid)
_host_io = p.stdio
_host_out = ""
_host_log = FileAccess.open(host_config.get_base_dir().path_join("server.log"), FileAccess.WRITE)
hosting = true
host_status.emit("Starting the server%s..." % (" (building a new island)" if fresh else ""), false)
## Reads what the hosted server prints (so its output never fills the pipe),
## and joins it once it's listening.
func _host_tick() -> void:
if _host_io == null:
return
var chunk := _host_io.get_buffer(8192)
if not chunk.is_empty():
var s := chunk.get_string_from_utf8()
if _host_log != null:
_host_log.store_string(s)
_host_log.flush()
if hosting and state == "offline":
_host_out += s
if _host_out.contains("listening on UDP"):
_host_out = ""
host_status.emit("Joining your server...", false)
join("127.0.0.1:%d" % _host_port, String(ServerConfig.open(host_config).data.password))
elif _host_out.contains("[server] Couldn't open UDP port") or _host_out.contains("Not starting"):
var why := "Port %d is already in use (another server running?)" % _host_port \
if _host_out.contains("Couldn't open") else "The server couldn't start (see user://host/server.log)"
stop_hosting()
host_status.emit(why, true)
if _host_pid != 0 and not OS.is_process_running(_host_pid):
var was_hosting := hosting
_host_pid = 0
# It's gone: whatever it printed last is still in the pipe.
for _i in range(64):
var rest := _host_io.get_buffer(8192)
if rest.is_empty():
break
if _host_log != null:
_host_log.store_string(rest.get_string_from_utf8())
if _host_log != null:
_host_log.flush()
_host_io = null
hosting = false
if was_hosting and state == "offline":
host_status.emit("The server stopped (see user://host/server.log)", true)
## Stops the hosted server: asks it to save and quit, then makes sure. Its
## output is still read (into the log) until it's gone.
func stop_hosting() -> void:
if not hosting:
return
hosting = false
var pid := _host_pid
if pid != 0:
get_tree().create_timer(6.0).timeout.connect(func():
if OS.is_process_running(pid):
OS.kill(pid))
## The addresses friends can use to reach this machine: the local network,
## and Tailscale (100.x) if it's running.
static func my_addresses() -> Array[String]:
var out: Array[String] = []
for a in IP.get_local_addresses():
if a.contains(":") or a.begins_with("127.") or a.begins_with("169.254."):
continue
out.append(a)
out.sort_custom(func(x, y): return (0 if x.begins_with("100.") else 1) < (0 if y.begins_with("100.") else 1))
return out
## A hosted server (started with --host-id): when the player who hosts it
## leaves, it saves and stops (it lives on their computer).
func _hosted_by(id: String) -> bool:
return is_server() and _args.has("host-id") and String(_args["host-id"]) == id
func _host_left() -> void:
print("[server] the host left: saving and stopping")
stop() # (saves the world and everyone's characters)
get_tree().quit()
# --- admin (chat.gd) ---------------------------------------------------------------
signal rules_changed ## clients: the server's rules changed (/set)
@@ -566,6 +698,7 @@ var _pending_password := ""
func leave() -> void:
var was := state
stop_hosting() # (our own server sees us go, and saves and stops)
stop()
if was == "connected":
last_reason = "You left the server"
@@ -659,6 +792,7 @@ func _run_queue(q: Array) -> void:
f.call()
func _fail(reason: String) -> void:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
stop()
last_reason = reason
join_failed.emit(reason)
@@ -669,6 +803,7 @@ func _on_connection_failed() -> void:
func _on_server_disconnected() -> void:
var was := state
var reason := _reject_reason
stop_hosting()
stop()
if was == "connecting":
last_reason = reason if not reason.is_empty() else "The server hung up"