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

136
docs/HOSTING.md Normal file
View File

@@ -0,0 +1,136 @@
# Hosting a game
Three ways to run a server, from easiest:
1. **Host Game** on the title screen. A server runs on your computer while
you play, and stops when you leave. Good for an evening with friends.
2. **A dedicated server** on a spare computer (like the home server Mac):
`server/run_server.sh`, or as a service that starts on boot. Good for a
world that's always there.
3. **A small cloud server** (Linux): the same as 2, using the systemd file.
Whichever you use, friends need to reach your computer on **UDP port
24242** (or the one you pick). On the same Wi-Fi that just works. From
elsewhere, use **Tailscale** (recommended) or port forwarding (below).
## 1. Host Game (from the title screen)
- Title screen > **Host Game**. Pick a server name, the rules (co-op,
friendly rivals or adversarial), an optional password, the port, and
(the first time) a seed. **Start**.
- The game starts a windowless copy of itself as the server, then joins it.
You're its first player, so you're its **admin** (`T` then `/help`).
- When you're in, a message says the addresses friends can join at: your
local network address (192.168.x.x) and, if Tailscale is running, your
100.x address.
- **Leaving** (Disconnect, or quitting the game) saves the island and
stops the server. Next time, Host Game carries on with the same island
(the seed field is locked once it exists).
- Its files: `~/Library/Application Support/Godot/app_userdata/Emberwild/host/`
(macOS; `%APPDATA%\Godot\app_userdata\Emberwild\host\` on Windows):
`server.json`, `server.log`, and the world in `worlds/server-world/`.
- macOS may ask once whether Godot may accept incoming network
connections: say **Allow**, or friends can't connect.
## 2. A dedicated server
On the machine that will run it (it needs Godot 4.7 or newer and a copy of
the game, e.g. a git clone):
```bash
server/run_server.sh # settings from server.json
server/run_server.sh --server-name "Paul's island" --password secret
```
It checks Godot's version, imports a fresh copy the first time, and starts
the server with no window. **You can type admin commands into its terminal**
(the same as in chat, without the `/`): `players`, `rules`,
`set pvp true`, `time 08:00`, `backup`, `say hello`... Stop it with Ctrl+C.
The first player to join a new server becomes its admin (so join it
yourself before telling friends the address). More admins: `/admin <name>`.
**As a service** (starts on boot, restarts if it stops):
- **macOS:** `server/com.emberwild.server.plist`: edit the paths in it,
then `cp server/com.emberwild.server.plist ~/Library/LaunchAgents/` and
`launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist`.
It runs while you're logged in (set the Mac to log in automatically for an
always-on server). Log: `~/Library/Logs/emberwild-server.log`.
- **Linux:** `server/emberwild-server.service`: the steps are in the file
(`sudo systemctl enable --now emberwild-server`; logs with
`journalctl -u emberwild-server -f`).
As a service there's no terminal to type commands into: use chat in game,
or edit `server.json` and restart.
## Settings and rules
`server.json` (made with defaults on first run; its path is printed at
start-up). Admins can change the rules in game with `/set` (they're saved
to the file). The full list is in `docs/MULTIPLAYER_PLAN.md` §4 and §6;
the ones you'll want first:
| Setting | Default | |
|---|---|---|
| `server_name`, `motd`, `password` | | what players see; a password keeps strangers out |
| `port` | 24242 | UDP |
| `max_players` | 8 | |
| `pvp`, `raiding` | false, false | can players hurt each other / take down others' builds |
| `locks` | owner_and_friends | who can open your chests and doors (`/friend <name>`) |
| `death_penalty` | grave | or `keep`, or `drop_all` |
| `difficulty` | normal | easy / normal / hard |
| `sleep` | all_online | or `half`, `anyone`, `off` |
| `backups_kept`, `backup_hours` | 10, 6 | see below |
| `allowlist`, `banned`, `admins` | | player ids (use `/ban`, `/admin` rather than editing) |
## Backups
The server copies the world (the island and every player's character) to
`worlds/<world>/backups/<date_time>/`:
- when it starts (the state it's about to load),
- every `backup_hours` while it runs,
- whenever an admin types `/backup` (`/backups` lists them).
It keeps the newest `backups_kept`. **To restore one:** stop the server,
copy the files from the backup folder (`meta.json`, `world.json` and the
`players` folder) into `worlds/<world>/`, replacing what's there, and start
it again.
The world also saves within 10 seconds of any change and when a player
leaves, so stopping it with Ctrl+C loses at most a few seconds.
## Letting friends in from outside your home
### Tailscale (recommended)
Tailscale puts your devices and your friends' on a private network, with
nothing opened to the internet and no router settings.
1. Install Tailscale on the server computer ([tailscale.com/download](https://tailscale.com/download))
and sign in.
2. Share the server with each friend: in the Tailscale admin console,
**Machines** > the server's **...** menu > **Share**, and send them the
link. (Or invite them to your tailnet.)
3. They install Tailscale, sign in and accept the share.
4. In the game they **Join** with the server's Tailscale address,
`100.x.y.z` (shown in Tailscale, and in the game when you host), or its
machine name (with MagicDNS on), e.g. `home-mac`.
To check it works: `tailscale ping <server name>` from the friend's
computer.
### Port forwarding
On your router, forward **UDP 24242** to the server computer's local
address (give that computer a fixed local address first). Friends join
with your public IP address. It needs no extra software, but your home IP
and that port are public, so set a `password`.
### A cloud server
Any small Linux server works (it needs little: a couple of GB of memory).
Install Godot, clone the game, use the systemd file, and open UDP 24242 in
the provider's firewall.
## Troubleshooting
- **"Different game version":** the server and the player are on different
versions of the game. Update both (`git pull`), and restart the server.
- **"No answer from the server":** wrong address, the server isn't running,
or a firewall is in the way (macOS: allow incoming connections for Godot;
Tailscale: is it connected on both ends?).
- **"Port 24242 is already in use":** another server is running on this
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
host on another port.
- **The server says the game's scripts didn't load:** it's a fresh copy
that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.

View File

@@ -363,7 +363,7 @@ player" or "this player":
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | | **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
| **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens | | **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
| **M2.6** *(done)* | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 | | **M2.6** *(done)* | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
| **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour | | **M2.7** *(done, see docs/HOSTING.md)* | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |
## 10. Testing ## 10. Testing
- **Headless multi-process tests:** a script starts a headless server and - **Headless multi-process tests:** a script starts a headless server and

View File

@@ -59,6 +59,7 @@ phases M2.0-M2.7).
- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)* - [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)*
- [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)* - [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)*
- [x] Server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat and admin commands, server console *(M2.6)* - [x] Server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat and admin commands, server console *(M2.6)*
- [x] Host Game from the title screen, backups, launchd and systemd samples, hosting and Tailscale guide (`docs/HOSTING.md`) *(M2.7)*
- **Done when:** a friend joins your self-hosted server and you build together. - **Done when:** a friend joins your self-hosted server and you build together.
## M3 — Addon SDK + sandbox · ~2–3 months ## M3 — Addon SDK + sandbox · ~2–3 months

View File

@@ -45,7 +45,9 @@ The shared island is saved in `worlds/server-world/` in that same folder,
within 10 seconds of any change (and when someone leaves). Stop it with within 10 seconds of any change (and when someone leaves). Stop it with
Ctrl+C. Players join with **Join Game** on the title screen. Over Ctrl+C. Players join with **Join Game** on the title screen. Over
Tailscale, use the server machine's 100.x address or its machine name. See Tailscale, use the server machine's 100.x address or its machine name. See
`docs/MULTIPLAYER_PLAN.md` for all the options. `docs/MULTIPLAYER_PLAN.md` for all the options, and `docs/HOSTING.md` for
hosting from the title screen, running it as a service, backups and
Tailscale.
## Tests ## Tests
Run everything (fails on any failed check or script error): Run everything (fails on any failed check or script error):

View File

@@ -8,14 +8,14 @@ extends Node
## ##
## Anyone: /help /players /rules /friend <name> /unfriend <name> /friends ## Anyone: /help /players /rules /friend <name> /unfriend <name> /friends
## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /save ## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /save
## /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto ## /backup /backups /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto
## /set <rule> <value> /say <message> /release-name <name> ## /set <rule> <value> /say <message> /release-name <name>
signal message(text: String, kind: String) ## client: show this (kind: chat | server | reply) signal message(text: String, kind: String) ## client: show this (kind: chat | server | reply)
const MAX_LEN := 200 const MAX_LEN := 200
const GAP := 0.4 ## seconds between one player's lines const GAP := 0.4 ## seconds between one player's lines
const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "time", "weather", "set", "say", "release-name"] const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "backup", "backups", "time", "weather", "set", "say", "release-name"]
var _last: Dictionary = {} ## peer -> ticks of their last line var _last: Dictionary = {} ## peer -> ticks of their last line
var _console: Thread var _console: Thread
@@ -107,7 +107,7 @@ func run(line: String, peer: int) -> void:
match cmd: match cmd:
"help": "help":
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" + _reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
(" · admin: /kick /ban /unban /admin /save /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else "")) (" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
"players": "players":
var names: Array[String] = [] var names: Array[String] = []
for p in Net.players.values(): for p in Net.players.values():
@@ -174,6 +174,13 @@ func run(line: String, peer: int) -> void:
"save": "save":
WorldNet.save() WorldNet.save()
_reply(peer, "Saved") _reply(peer, "Saved")
"backup":
WorldNet.save()
var b: String = WorldNet.backup("by %s" % by)
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
"backups":
var all: Array = WorldNet.list_backups()
_reply(peer, "%d backups, newest last: %s" % [all.size(), ", ".join(PackedStringArray(all.slice(-5)))] if not all.is_empty() else "No backups yet")
"time": "time":
var hm := arg.split(":") var hm := arg.split(":")
if hm.size() < 1 or not hm[0].is_valid_int() or int(hm[0]) > 23 or (hm.size() > 1 and not hm[1].is_valid_int()): if hm.size() < 1 or not hm[0].is_valid_int() or int(hm[0]) > 23 or (hm.size() > 1 and not hm[1].is_valid_int()):

View File

@@ -240,6 +240,8 @@ func _on_peer_disconnected(id: int) -> void:
_s2c_player_left.rpc(id) _s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id) WorldNet.peer_left(id)
if _hosted_by(String(p.id)):
_host_left.call_deferred()
players_changed.emit() players_changed.emit()
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -255,6 +257,7 @@ func _process(delta: float) -> void:
_send_left = SEND_INTERVAL _send_left = SEND_INTERVAL
_broadcast_snapshot() _broadcast_snapshot()
return return
_host_tick()
if state == "connecting": if state == "connecting":
_join_left -= delta _join_left -= delta
if _join_left <= 0.0: if _join_left <= 0.0:
@@ -272,6 +275,135 @@ func _tick_clock(delta: float) -> void:
for peer in players: for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) _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) --------------------------------------------------------------- # --- admin (chat.gd) ---------------------------------------------------------------
signal rules_changed ## clients: the server's rules changed (/set) signal rules_changed ## clients: the server's rules changed (/set)
@@ -566,6 +698,7 @@ var _pending_password := ""
func leave() -> void: func leave() -> void:
var was := state var was := state
stop_hosting() # (our own server sees us go, and saves and stops)
stop() stop()
if was == "connected": if was == "connected":
last_reason = "You left the server" last_reason = "You left the server"
@@ -659,6 +792,7 @@ func _run_queue(q: Array) -> void:
f.call() f.call()
func _fail(reason: String) -> void: func _fail(reason: String) -> void:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
stop() stop()
last_reason = reason last_reason = reason
join_failed.emit(reason) join_failed.emit(reason)
@@ -669,6 +803,7 @@ func _on_connection_failed() -> void:
func _on_server_disconnected() -> void: func _on_server_disconnected() -> void:
var was := state var was := state
var reason := _reject_reason var reason := _reject_reason
stop_hosting()
stop() stop()
if was == "connecting": if was == "connecting":
last_reason = reason if not reason.is_empty() else "The server hung up" last_reason = reason if not reason.is_empty() else "The server hung up"

View File

@@ -25,6 +25,7 @@ const DEFAULTS := {
"difficulty": "normal", "difficulty": "normal",
"autosave_minutes": 2, "autosave_minutes": 2,
"backups_kept": 10, "backups_kept": 10,
"backup_hours": 6,
"admins": [], "admins": [],
"allowlist": [], "allowlist": [],
"banned": [], "banned": [],

View File

@@ -50,6 +50,8 @@ func open_server_world(config: RefCounted) -> Node3D:
if id.is_empty(): if id.is_empty():
id = "server-world" id = "server-world"
Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) Saves.current_id = Saves.open_or_create(id, int(config.v("seed")))
backup("start-up") # (the state it's about to load, before anyone changes it)
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
var w: Node3D = load("res://scenes/world.tscn").instantiate() var w: Node3D = load("res://scenes/world.tscn").instantiate()
w.server_mode = true w.server_mode = true
w.day_length = float(config.v("day_length_minutes")) * 60.0 w.day_length = float(config.v("day_length_minutes")) * 60.0
@@ -97,6 +99,66 @@ func save() -> void:
if world != null and is_instance_valid(world) and world.server_mode: if world != null and is_instance_valid(world) and world.server_mode:
world.save_now() # (players too: world.saved) world.save_now() # (players too: world.saved)
# --- backups (M2.7) ----------------------------------------------------------------
# A copy of the world (meta, world and every player's file) in
# worlds/<world>/backups/<yyyy-mm-dd_hh-mm-ss>/: when the server starts (the
# state it's about to load), every backup_hours while it runs, and on
# /backup. The newest backups_kept are kept. To restore one, stop the server
# and copy its files back up two folders (docs/HOSTING.md).
var _backup_left := INF
func world_dir() -> String:
return Saves.root_dir.path_join(Saves.current_id)
## Makes a backup now. Returns its folder name ("" if there was nothing to back up).
func backup(reason: String) -> String:
var src := world_dir()
if not FileAccess.file_exists(src.path_join("world.json")):
return ""
var stamp := Time.get_datetime_string_from_system(false, true).replace(":", "-").replace(" ", "_")
var dst := src.path_join("backups").path_join(stamp)
var n := 2
while DirAccess.dir_exists_absolute(dst):
dst = src.path_join("backups").path_join("%s_%d" % [stamp, n])
n += 1
DirAccess.make_dir_recursive_absolute(dst.path_join("players"))
for f in ["meta.json", "world.json"]:
if FileAccess.file_exists(src.path_join(f)):
DirAccess.copy_absolute(src.path_join(f), dst.path_join(f))
var pd := DirAccess.open(src.path_join("players"))
if pd != null:
for f in pd.get_files():
if f.ends_with(".json"):
DirAccess.copy_absolute(src.path_join("players").path_join(f), dst.path_join("players").path_join(f))
_prune_backups()
print("[server] backup (%s): %s" % [reason, ProjectSettings.globalize_path(dst)])
return dst.get_file()
func list_backups() -> Array:
var d := DirAccess.open(world_dir().path_join("backups"))
if d == null:
return []
var out: Array = Array(d.get_directories())
out.sort()
return out
func _prune_backups() -> void:
var keep := maxi(int(Net.config.v("backups_kept")), 1)
var all := list_backups()
while all.size() > keep:
_rm_tree(world_dir().path_join("backups").path_join(String(all.pop_front())))
static func _rm_tree(d: String) -> void:
var da := DirAccess.open(d)
if da == null:
return
for sub in da.get_directories():
_rm_tree(d.path_join(sub))
for f in da.get_files():
da.remove(f)
DirAccess.remove_absolute(d)
## Something changed: save within a few seconds. Ctrl+C kills Godot without ## Something changed: save within a few seconds. Ctrl+C kills Godot without
## a chance to save, so this keeps the loss small. ## a chance to save, so this keeps the loss small.
func mark_changed() -> void: func mark_changed() -> void:
@@ -139,6 +201,11 @@ func _process(delta: float) -> void:
_save_left -= delta _save_left -= delta
if _save_left <= 0.0: if _save_left <= 0.0:
save() save()
_backup_left -= delta
if _backup_left <= 0.0:
_backup_left = maxf(float(Net.config.v("backup_hours")), 0.25) * 3600.0
save()
backup("every %s h" % Net.config.v("backup_hours"))
_blocker_left -= delta _blocker_left -= delta
if _blocker_left <= 0.0: if _blocker_left <= 0.0:
_blocker_left = 1.0 _blocker_left = 1.0

View File

@@ -87,8 +87,10 @@ func _ready() -> void:
_pages["profile"] = _profile_page() _pages["profile"] = _profile_page()
_pages["join"] = _join_page() _pages["join"] = _join_page()
_pages["lobby"] = _lobby_page() _pages["lobby"] = _lobby_page()
_pages["host"] = _host_page()
for p in _pages.values(): for p in _pages.values():
col.add_child(p) col.add_child(p)
Net.host_status.connect(_on_host_status)
Net.joined.connect(_enter_server_world) Net.joined.connect(_enter_server_world)
Net.join_failed.connect(_on_join_failed) Net.join_failed.connect(_on_join_failed)
Net.left.connect(_on_left) Net.left.connect(_on_left)
@@ -127,7 +129,7 @@ func _maybe_capture() -> void:
var me: RefCounted = Net.get_identity() var me: RefCounted = Net.get_identity()
if me.name.is_empty(): if me.name.is_empty():
me.name = "Paul" # in memory only, for the pictures me.name = "Paul" # in memory only, for the pictures
for page in ["main", "new", "load", "profile", "join", "lobby"]: for page in ["main", "new", "load", "profile", "join", "host", "lobby"]:
if page == "lobby": if page == "lobby":
# Sample data: the lobby normally needs a live server. # Sample data: the lobby normally needs a live server.
Net.server_info = {"server_name": "Paul's island", "motd": "Welcome! Be nice to the wolves.", Net.server_info = {"server_name": "Paul's island", "motd": "Welcome! Be nice to the wolves.",
@@ -147,7 +149,8 @@ func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
if _pages["lobby"].visible: if _pages["lobby"].visible:
Net.leave() Net.leave()
elif Net.state == "connecting": elif Net.state == "connecting" or Net.hosting:
Net.stop_hosting()
Net.stop() Net.stop()
_join_msg.text = "" _join_msg.text = ""
_show("main") _show("main")
@@ -155,16 +158,19 @@ func _unhandled_input(event: InputEvent) -> void:
_show("main") _show("main")
func _show(page: String) -> void: func _show(page: String) -> void:
# Joining needs a name: send people to their profile first. # Joining (or hosting) needs a name: send people to their profile first.
if page == "join" and Identity.name_problem(Net.get_identity().name) != "": if page in ["join", "host"] and Identity.name_problem(Net.get_identity().name) != "":
_after_profile = "join" _after_profile = page
page = "profile" page = "profile"
if page == "host":
_fill_host()
for k in _pages: for k in _pages:
_pages[k].visible = k == page _pages[k].visible = k == page
_armed_delete = "" _armed_delete = ""
if page == "profile": if page == "profile":
_fill_profile() _fill_profile()
elif page == "join": elif page == "join":
_join_shown_at = Time.get_ticks_msec()
_join_addr.text = Net.get_identity().last_server _join_addr.text = Net.get_identity().last_server
_join_btn.disabled = false _join_btn.disabled = false
_join_addr.grab_focus() _join_addr.grab_focus()
@@ -199,6 +205,7 @@ func _main_page() -> Control:
var lb := _button("Load World", _show.bind("load")) var lb := _button("Load World", _show.bind("load"))
lb.disabled = worlds.is_empty() lb.disabled = worlds.is_empty()
v.add_child(lb) v.add_child(lb)
v.add_child(_button("Host Game", _show.bind("host")))
v.add_child(_button("Join Game", _show.bind("join"))) v.add_child(_button("Join Game", _show.bind("join")))
var n: String = Net.get_identity().name var n: String = Net.get_identity().name
v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile", v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile",
@@ -315,6 +322,119 @@ func _save_profile() -> void:
_rebuild_main() _rebuild_main()
_show(_after_profile) _show(_after_profile)
var _join_shown_at := -100000
# --- hosting (M2.7) -------------------------------------------------------------
var _host_name: LineEdit
var _host_pw: LineEdit
var _host_port: LineEdit
var _host_seed: LineEdit
var _host_seed_label: Label
var _host_preset: OptionButton
var _host_msg: Label
var _host_btn: Button
const PRESET_IDS := ["co-op", "rivals", "adversarial"]
const PRESET_NAMES := ["Co-op: no PvP, bases safe, friends share", "Friendly rivals: PvP on, bases safe",
"Adversarial: PvP, raiding, locks, drop everything on death"]
func _host_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 7)
p.add_child(v)
v.add_child(UITheme.label("Host a game", 24))
v.add_child(UITheme.label("Runs a server on this computer and puts you in it. Friends join with your address.", 13, UITheme.TEXT_DIM))
v.add_child(UITheme.label("Server name", 15, UITheme.TEXT_DIM))
_host_name = LineEdit.new()
_host_name.custom_minimum_size = Vector2(420, 38)
_host_name.max_length = 40
v.add_child(_host_name)
v.add_child(UITheme.label("Rules", 15, UITheme.TEXT_DIM))
_host_preset = OptionButton.new()
for n in PRESET_NAMES:
_host_preset.add_item(n)
_host_preset.custom_minimum_size = Vector2(420, 36)
v.add_child(_host_preset)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var c1 := VBoxContainer.new()
c1.add_child(UITheme.label("Password (optional)", 15, UITheme.TEXT_DIM))
_host_pw = LineEdit.new()
_host_pw.secret = true
_host_pw.custom_minimum_size = Vector2(200, 38)
c1.add_child(_host_pw)
row.add_child(c1)
var c2 := VBoxContainer.new()
c2.add_child(UITheme.label("Port (UDP)", 15, UITheme.TEXT_DIM))
_host_port = LineEdit.new()
_host_port.custom_minimum_size = Vector2(100, 38)
c2.add_child(_host_port)
row.add_child(c2)
var c3 := VBoxContainer.new()
_host_seed_label = UITheme.label("Seed", 15, UITheme.TEXT_DIM)
c3.add_child(_host_seed_label)
_host_seed = LineEdit.new()
_host_seed.placeholder_text = "random"
_host_seed.custom_minimum_size = Vector2(100, 38)
c3.add_child(_host_seed)
row.add_child(c3)
v.add_child(row)
_host_msg = UITheme.label("", 14, UITheme.TEXT_DIM)
_host_msg.autowrap_mode = TextServer.AUTOWRAP_WORD
_host_msg.custom_minimum_size = Vector2(420, 0)
v.add_child(_host_msg)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
_host_btn = _button("Start", _start_host, 140)
h.add_child(_host_btn)
h.add_child(_button("Back", func():
Net.stop_hosting()
Net.stop()
_show("main"), 120))
v.add_child(h)
return p
func _fill_host() -> void:
var c: RefCounted = load("res://scripts/net/server_config.gd").open(Net.host_config)
var n: String = c.data.server_name
_host_name.text = "%s's island" % Net.get_identity().name if n == "Emberwild server" else n
_host_port.text = str(int(c.data.port))
_host_pw.text = String(c.data.password)
var preset := "co-op"
if bool(c.data.pvp):
preset = "adversarial" if bool(c.data.raiding) else "rivals"
_host_preset.select(PRESET_IDS.find(preset))
var meta_path: String = Net.host_config.get_base_dir().path_join("worlds/server-world/meta.json")
var existing: bool = FileAccess.file_exists(meta_path)
_host_seed.editable = not existing
_host_seed_label.text = "Seed" if not existing else "Seed (kept)"
_host_seed.text = "" if not existing else str(int(c.data.seed))
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made."
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
_host_btn.disabled = false
func _start_host() -> void:
var port_t := _host_port.text.strip_edges()
if not port_t.is_valid_int() or int(port_t) < 1024 or int(port_t) > 65535:
_on_host_status("The port is a number from 1024 to 65535 (24242 is the usual one)", true)
return
var seed_t := _host_seed.text.strip_edges()
var seed := int(seed_t) if seed_t.is_valid_int() else (seed_t.hash() if not seed_t.is_empty() else randi() % 1000000)
var name := _host_name.text.strip_edges()
if name.is_empty():
name = "%s's island" % Net.get_identity().name
_host_btn.disabled = true
Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed)
func _on_host_status(text: String, failed: bool) -> void:
if not _pages["host"].visible:
return
_host_msg.text = text
_host_msg.add_theme_color_override("font_color", UITheme.BAD if failed else UITheme.TEXT_DIM)
if failed:
_host_btn.disabled = false
func _join_page() -> Control: func _join_page() -> Control:
var p := UITheme.panel() var p := UITheme.panel()
var v := VBoxContainer.new() var v := VBoxContainer.new()
@@ -350,6 +470,9 @@ func _join_page() -> Control:
return p return p
func _connect() -> void: func _connect() -> void:
# (A click or Enter carried over from the page before can't connect.)
if Time.get_ticks_msec() - _join_shown_at < 400:
return
if _join_addr.text.strip_edges().is_empty(): if _join_addr.text.strip_edges().is_empty():
_join_msg.text = "Enter the server's address" _join_msg.text = "Enter the server's address"
_join_msg.add_theme_color_override("font_color", UITheme.BAD) _join_msg.add_theme_color_override("font_color", UITheme.BAD)
@@ -360,6 +483,9 @@ func _connect() -> void:
Net.join(_join_addr.text, _join_pw.text) Net.join(_join_addr.text, _join_pw.text)
func _on_join_failed(reason: String) -> void: func _on_join_failed(reason: String) -> void:
if _pages["host"].visible:
_on_host_status(reason, true)
return
_join_btn.disabled = false _join_btn.disabled = false
_join_msg.text = reason _join_msg.text = reason
_join_msg.add_theme_color_override("font_color", UITheme.BAD) _join_msg.add_theme_color_override("font_color", UITheme.BAD)

View File

@@ -756,6 +756,9 @@ func _go_online() -> void:
bar.position = Vector2(16, 40) bar.position = Vector2(16, 40)
$HUD.add_child(bar) $HUD.add_child(bar)
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server")) Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
if Net.hosting:
var at := Net.my_addresses()
Actions.notify.emit("You're hosting. Friends join at %s" % (", ".join(PackedStringArray(at)) if not at.is_empty() else "this computer's address"))
func _on_net_left(_reason: String) -> void: func _on_net_left(_reason: String) -> void:
WorldNet.leave_world() WorldNet.leave_world()

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Emberwild dedicated server as a launchd agent (macOS): starts when you log
in, and restarts if it stops.
1. Edit the two paths below (the game folder, and your user name in the log
path) if yours differ.
2. Copy it: cp server/com.emberwild.server.plist ~/Library/LaunchAgents/
3. Start it: launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist
Stop it: launchctl unload ~/Library/LaunchAgents/com.emberwild.server.plist
Log: tail -f ~/Library/Logs/emberwild-server.log
server.json and the world are in
~/Library/Application Support/Godot/app_userdata/Emberwild/server/
For admin commands, join the game and use chat (/help), or edit server.json
and stop/start it. (For a console, run server/run_server.sh in Terminal
instead.)
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.emberwild.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/home/valheimlike/server/run_server.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/home/valheimlike</string>
<key>EnvironmentVariables</key>
<dict>
<!-- launchd's PATH is minimal: point at Godot directly. -->
<key>GODOT</key>
<string>/Applications/Godot.app/Contents/MacOS/Godot</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>15</integer>
<key>StandardOutPath</key>
<string>/Users/home/Library/Logs/emberwild-server.log</string>
<key>StandardErrorPath</key>
<string>/Users/home/Library/Logs/emberwild-server.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,36 @@
# Emberwild dedicated server as a systemd service (Linux).
#
# 1. Put the game where it'll live, e.g. /opt/emberwild (a git clone), and
# install Godot 4.7+ as /usr/local/bin/godot.
# 2. Make a user for it: sudo useradd -r -m -d /var/lib/emberwild emberwild
# 3. Copy this file: sudo cp server/emberwild-server.service /etc/systemd/system/
# and edit User, WorkingDirectory and GODOT below if yours differ.
# 4. Start it, and on boot: sudo systemctl enable --now emberwild-server
# Logs: journalctl -u emberwild-server -f
# Stop / restart: sudo systemctl stop|restart emberwild-server
#
# server.json and the world live in the user's Godot data folder:
# /var/lib/emberwild/.local/share/godot/app_userdata/Emberwild/server/
# Admin commands: join the game and use chat (/help), or edit server.json and
# restart. (The console needs a terminal, so it isn't used here.)
[Unit]
Description=Emberwild dedicated server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=emberwild
WorkingDirectory=/opt/emberwild
Environment=GODOT=/usr/local/bin/godot
# run_server.sh checks the Godot version and imports a fresh copy first.
ExecStart=/opt/emberwild/server/run_server.sh
Restart=on-failure
RestartSec=10
# The world saves within 10 s of any change, so a stop loses at most that.
KillSignal=SIGINT
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi fi
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_terrain_edges test_boundary; do for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

148
tests/test_host.gd Normal file
View File

@@ -0,0 +1,148 @@
extends SceneTree
## M2.7: hosting from the title screen, and backups. "Host Game" starts a
## server as a windowless copy of the game, joins it (you're its admin),
## and leaving makes it save and stop. Hosting again carries on the same
## island. The server backs the world up at start-up and on /backup, and
## keeps only backups_kept of them. Also: Tailscale/LAN addresses are found.
## Run: godot --headless --path . --script res://tests/test_host.gd
const DIR := "user://test_net/host"
const PORT := 24351
var _fails := 0
var _finished := false
var Net: Node
var WN: Node
var C: Node
var PN: Node
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
Net = root.get_node("Net")
WN = root.get_node("WorldNet")
C = root.get_node("Chat")
PN = root.get_node("PlayerNet")
_rm_tree(DIR)
Net.host_config = DIR + "/server.json"
var me = load("res://scripts/net/identity.gd").open("user://test_net/a")
me.name = "Alice"
Net.identity = me
await _body()
Net.leave()
await _sleep(1.0)
check(_finished, "test ran to the end (no script errors)")
_rm_tree(DIR)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body() -> void:
var statuses: Array[String] = []
Net.host_status.connect(func(t, _f): statuses.append(t))
print("hosting")
Net.host("Alice's island", "", PORT, "rivals", 4242)
check(Net.hosting, "Host Game starts a server (%s)" % ", ".join(statuses))
await _until(func(): return Net.state == "connected", 40.0)
check(Net.state == "connected", "and joins it (%s)" % " -> ".join(statuses))
if Net.state != "connected":
return
check(String(Net.server_info.get("server_name", "")) == "Alice's island" and bool(Net.server_info.get("pvp", false)),
"with the name and the rules picked (friendly rivals: PvP on)")
check(bool(Net.players.get(Net.local_peer, {}).get("admin", false)), "and Alice is its admin")
check(int(Net.world_info.get("seed", 0)) == 4242, "a new island from the seed given (%d)" % int(Net.world_info.get("seed", 0)))
var addrs: Array[String] = Net.my_addresses()
check(not addrs.is_empty(), "the addresses friends can use: %s" % ", ".join(addrs))
var log_path := ProjectSettings.globalize_path(DIR + "/server.log")
check(FileAccess.get_file_as_string(DIR + "/server.log").contains("listening on UDP %d" % PORT), "the server's log goes to %s" % log_path)
print("backups")
check(_backups().is_empty(), "nothing to back up yet on a brand-new island")
# Load a world so there's something to save, change it, then /backup.
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
world.player.set_physics_process(false)
PN.ask("debug_give", ["stone", 3]) # (not allowed: no --debug-cheats on a hosted server)
await _sleep(0.5)
check(world.player.inventory.count("stone") == 0, "a hosted server has no test cheats")
C.say("/backup")
await _until(func(): return _backups().size() == 1, 5.0)
check(_backups().size() == 1, "/backup makes one (%s)" % [_backups()])
var b0: String = _backups()[0] if not _backups().is_empty() else ""
var bdir := DIR + "/worlds/server-world/backups/" + b0
check(FileAccess.file_exists(bdir + "/world.json") and FileAccess.file_exists(bdir + "/meta.json"),
"with the world in it")
check(DirAccess.get_files_at(bdir + "/players").size() >= 1, "and the players' characters")
C.say("/set backups_kept 2")
await _sleep(0.4)
check(C.heard.any(func(l): return String(l).begins_with("Can't set 'backups_kept'")), "(backups_kept is set in server.json, not in game)")
for _i in range(3):
await _sleep(1.1) # (one-second timestamps)
C.say("/backup")
await _sleep(1.0)
check(_backups().size() == 4, "four backups now (%d)" % _backups().size())
world.queue_free()
await process_frame
print("leaving")
Net.leave()
await _until(func(): return not _server_running(), 8.0)
check(not _server_running(), "leaving stops the server")
# (Its last words are read from the pipe once the process has gone.)
await _until(func(): return FileAccess.get_file_as_string(DIR + "/server.log").contains("the host left"), 3.0)
check(FileAccess.get_file_as_string(DIR + "/server.log").contains("the host left: saving and stopping"),
"after saving (it was asked to, not killed)")
print("hosting again")
var c = load("res://scripts/net/server_config.gd").open(DIR + "/server.json")
c.data.backups_kept = 3
c.save()
statuses.clear()
Net.host("Alice's island", "", PORT, "co-op", 999)
await _until(func(): return Net.state == "connected", 40.0)
check(Net.state == "connected", "Host Game again: joined (%s)" % " -> ".join(statuses))
check(int(Net.world_info.get("seed", 0)) == 4242, "the same island (seed %d, not the new one typed)" % int(Net.world_info.get("seed", 0)))
check(not bool(Net.server_info.get("pvp", true)), "with the rules picked this time (co-op: PvP off)")
check(_backups().size() == 3, "start-up made a backup, and only backups_kept (3) are kept (%d)" % _backups().size())
_finished = true
## Whether anything is still listening on the hosted server's port.
func _server_running() -> bool:
var out: Array = []
OS.execute("lsof", ["-nP", "-iUDP:%d" % PORT], out)
return String(out[0] if not out.is_empty() else "").contains("UDP")
func _backups() -> Array:
var d := DirAccess.open(DIR + "/worlds/server-world/backups")
if d == null:
return []
var a: Array = Array(d.get_directories())
a.sort()
return a
func _until(cond: Callable, limit: float) -> bool:
var t := 0.0
while not cond.call() and t < limit:
await _sleep(0.1)
t += 0.1
return cond.call()
func _sleep(s: float) -> void:
await create_timer(s).timeout
static func _rm_tree(d: String) -> void:
var da := DirAccess.open(d)
if da == null:
return
for sub in da.get_directories():
_rm_tree(d.path_join(sub))
for f in da.get_files():
da.remove(f)
DirAccess.remove_absolute(d)