Refused joins, crossplay and hosted worlds
Three join variants beside the plain dedicated-server join. The plain join is on Joining, protecting and placing a player; the rounds that run every variant are on Client rounds. All three use ValheimCLI commands the pinned build already has.
Expect a refused join
SessionControl.JoinExpectingRefusal(address, character, expected, menuExpectations, timeout) tests that a server refuses a client. The usual case is a mod's version check refusing a mismatched client with GameConnectionStatus.ErrorVersion (3). It:
- turns devcommands on, as
Joindoes, and issuescli_extension valheim.session/join <host:port> <character> [password-variable]once; - requires ValheimCLI's structured failure with code
join_failed, which ValheimCLI reports when the new connection ends in an error status; - re-pins the client with
menuExpectations(plugins only, such asClientRunPlan.MenuExpectations) and readsvalheim.session/stateuntil the client is back at its menu (phasemenu, no world); - reads
cli_connection_statusonce (OK: connectionStatus=ErrorVersion, server=...) and requires the expected status.
Nothing is retried. It returns a JoinRefusal with the status, its number (Code), the server string and the time taken. The client is then at its menu, pinned; check that the server accepts a matching client with a normal Join.
| Fails when | What you see |
|---|---|
| The join succeeds | The client is in the server's world: leave it before another join |
| ValheimCLI reports another code | character_unavailable, transition_timeout, load_error, ... |
The client is not back at its menu within timeout |
The wait expires |
| The refusal has another status | The expected and read statuses are named |
GameConnectionStatus has the game's names and numbers:
| Number | Name |
|---|---|
| 3 | ErrorVersion |
| 4 | ErrorDisconnected |
| 5 | ErrorConnectFailed |
| 6 | ErrorPassword |
| 7 | ErrorAlreadyConnected |
| 8 | ErrorBanned |
| 9 | ErrorFull |
| 10 | ErrorPlatformExcluded |
| 11 | ErrorCrossplayPrivilege |
| 12 | ErrorKicked |
ConnectionStatusReading.Read(actor) reads the status at any time. In Valheim 1.0.16 a refused client keeps its status at the menu until its next join starts.
Limit. The game can overwrite an error with ErrorDisconnected when the connection closes after it. Check the status another mod's refusal leaves in a native run before you expect it.
Crossplay (PlayFab)
Clients join a crossplay server by its PlayFab id, not its address.
- Server.
"crossplay": truein aServerRunPlanmakes the runner launch the dedicated server with-crossplay(LaunchArguments) and recordcrossplay(trueorfalse) in every report's provenance.-crossplayinargumentsis refused for every plan the runner reads. - Port rule. A lobby is keyed by the server's public IP and game port. Two crossplay servers behind one public IP need different game ports, or the second takes the first one's joins. A crossplay plan names exactly one
-port N(1024 to 65535), never the default 2456. - Separate fixtures. Keep a crossplay fixture server separate from a Steam one. A client remembers each server's mode in its recent-server list.
- No password. Run the fixture server private without a password (
-public 0, no-password). The client's join command,cli_connect_playfab_user, takes a password as command text, which would be recorded. - Lobby.
CrossplayServer.WaitForLobby(server, CrossplayServer.BepInExLog(runtime), timeout)first readscli_multiplayer_identity(OK: steamId=..., playFabLoginState=..., playFabId=..., backend=PlayFab, gameState=..., connectionStatus=..., isServer=True, isOpenServer=True, server=...) and refuses a backend other thanPlayFab. It then reads this boot's server log from its start forCreated PlayFab lobby with ID "...", ConnectionString "..." and owned by "<id>". A failed PlayFab login ends the wait at once. The owner id is what the client joins;playFabIdcan beunavailable, and when present it must equal the owner. BepInEx copies the game's lines intoLogOutput.logby default; otherwise pass the Unity log the plan's-logFilenames. For a server on another machine,ServerActor.Lobby(boot)waits for the same line in the host's log. - Client.
"crossplay": truein aClientRunPlan, with nojoinand nopasswordVariable.SessionControl.JoinCrossplay(remotePlayerId, character, worldUid, menuExpectations, timeout)joins from the idle menu: devcommands on, thencli_select_character <character>(OK: Selected character '...') andcli_connect_playfab_user <id>once (OK: PlayFab user join started for <id> using ...). The command only starts the join, so it re-pins with the menu pins and reads the session state until the client is connected inworldUidwith its player ready. A load error, another world, or a return to the menu with anError...status after the join started fails it; a status left from an earlier join does not count. The world can load before the menu pins are checked, and a strict client then refuses them (world: ... is loaded but not listed (strict)). PassworldExpectations(ClientRunPlan.WorldExpectations(worldUid)) and the client is re-pinned to that world once, on that refusal only;ClientRoundspasses it. - Linux server. A Linux dedicated server needs
libpulse0andlibpulse-mainloop-glib0: the game's PlayFab library (valheim_server_Data/Plugins/libparty.so) loads them. Without them Unity logsFailed to open plugin, the first PlayFab call fails withDllNotFoundException: libParty.so, and the server retries its network every 30 s without a lobby. The Linux server image has them. A crossplayPinnedServerRunchecks this before any server starts, invalidatetoo, as the step "the server host can load crossplay's libraries".CrossplayLibraries.RequireAsyncruns the host'slddon the server'slibparty.sowith the launch's library path. It refuses a missing library by name, with its Debian or Ubuntu package and theapt-get installline, a runtime withoutlibparty.so, and a host withoutldd; it recordscrossplayLibrariesin the report.HostServer.StartAsyncmakes the same check for any launch with-crossplay. - Stopping. A killed server leaves its lobby active for hours; a later join on the same IP and port, or the same PlayFab identity, can reach the dead lobby and fail with
ErrorConnectFailed(5). Owned servers are asked to quit first. On a clean quit the game saves, logsUnregister PlayFab server ...and asks PlayFab to mark its lobby inactive; it logsDeactivated PlayFab lobby <id>only if PlayFab's reply arrives before the process ends. A crossplayPinnedServerRunends with the step "every boot quit cleanly and retired its crossplay lobby" (CrossplayServer.RequireLobbiesRetired): every boot, restarts included, must have quit cleanly, and its kept logs (BepInEx's, the-logFilefile, the process output) must show the lobby it created and theUnregisterline. A killed boot fails the run, and so do logs without any lobby line.crossplayLobbieslists each boot's lobby and whether PlayFab's confirmation was logged. The lobby line is in the log the game writes to: the-logFilefile when the plan passes one (ServerRunPlan.GameLogFile); the BepInEx log may not carry it. - Rounds.
ClientRoundswith a crossplay client needsLobby, for exampleserver => CrossplayServer.WaitForLobby(server, log, timeout). It reads the lobby before each round's join, because a restarted server opens a new one, and recordsclientJoin(crossplayoraddress).
Hosted (listen-server) worlds
A client can host a world from its menu instead of joining a server. The host is the server of its world and also has a local player: its session state reads server true and dedicated false, so a broadcast RPC runs the server and client handlers in one process. The hostWorld section of a ClientRunPlan describes the world:
| Field | Meaning |
|---|---|
world |
The fixture as a pinned directory holding one world, in either layout below |
worldUid |
The fixture world's exact UID; the game silently creates a fresh world when the named one is missing |
crossplay |
Host a crossplay world |
saveDirectory |
The client's data directory, which holds worlds_local; default below |
saveSeconds |
The confirmed save's timeout, 1 to 600 (default 120) |
The fixture holds Valheim 1.0's chunked save, one <name>/ directory at its root with _main.<n>.fwl2 and its chunks, or the older pair, <name>.fwl at its root with <name>.db beside it. Every entry is named for the world; the name is at least 3 characters and one token. saveDirectory defaults to this user's: AppData/LocalLow/IronGate/Valheim on Windows, Library/Application Support/IronGate/Valheim on macOS. Set it when a Windows client runs as another user. A native macOS hosted run requires the signed-in user's default directory; a custom path or -savedir is refused before fixture placement.
ClientRunPlan.Validate refuses a hosting client with join, passwordVariable or the client-level crossplay ("A hosting client (hostWorld) joins no server: leave out join, passwordVariable and crossplay; hostWorld.crossplay hosts a crossplay world."), one that is also joinsHost, and one whose ValheimCLI host is not this machine ("A hosting client runs on this machine, where the runner places the fixture world in its save directory; its ValheimCLI host is 127.0.0.1."). It also refuses a fixture that does not name one world, a missing or inexact worldUid, and, when pinned, a fixture without hashes. Unknown fields are refused.
Every hosted run places the fixture through the one journalled placement in Valheim.Testing.GameSessions (a campaign's client on its host; valheim-test start, a session's HostingClientActor and HostedClientRounds.Run on this machine). It first refuses if anything named for the world is already in the client's worlds_local: <name>, <name>.* or <name>_backup*, in any case. That is what the game reads and writes for a world (its .fwl, .db and .old copies, Valheim 1.0's chunked <name>/ folder and its _backup_auto- backups), and Windows and macOS folders ignore case. So your own world is never overwritten and a refused run copies nothing. It then copies and verifies the fixture into the output directory, keeps that copy, journals the placement with the fixture's world UID, and places the fixture in worlds_local. Collect moves the world, with what the game wrote for it (saves, .old files, backups: the same entries the refusal looks for), into the run's folder (<runId>/host-world in the output on this machine) and fetches it into host-world in the output directory.
HostWorlds.Start(host, plan, worldName, timeout) starts from the idle menu:
- turns devcommands on (ValheimCLI refuses its save and leave without them) and selects the plan's character (
cli_select_character); - sends
cli_start_host_world <name> --public false --crossplay true|falseonce and requires the replyOK: Starting hosted world '<name>' using <character>; open=true, public=False, crossplay=<True|False>, backend=<PlayFab|Steamworks>, passwordSet=False; - re-pins the client with its menu pins and waits for the plan's world UID to be ready; a fresh world has another UID and fails here, naming both UIDs as the wrong world;
- verifies the world pins, then waits for the host's player and protects it (
WaitForWorld). A world that loads between two reads makes the strict menu pins refuse it ("loaded but not listed"); the world pins are then checked at once.
HostWorlds.Restart leaves the world, which saves it, then starts it again. The host's world is never restarted as a separate process.
ClientRounds.Run(openClient, measure) runs a client whose plan has a hostWorld section; it is HostedClientRounds.Run in Valheim.Testing.GameSessions (add that package and using Valheim.Testing.GameSessions;), which places the world through the journalled placement above. It refuses OwnedServer, Lobby, Arrival and ArriveStep, which only a joining client uses, and its WorldUid is the fixture's (leave it out). It first runs the plan's Preflight(CliCapabilities.HostedRounds) (what it catches), so a wrong fixture, install or ValheimCLI set stops the run before the fixture is copied or the client started. It then places the world, opens the client (owned or attached), requires CliCapabilities.HostedRounds live and runs each round, bounding each start by the plan's joinSeconds:
- hosts the world, a restart from the second round on, and protects the host's player unless
ProtectPlayeris false; - runs the mod's measurement;
round.Serverandround.Clientare the same host; - between rounds, saves with a confirmed
valheim.session/saveon the host; - the host leaves to its menu.
The report's provenance records role = host, hostWorld, hostCrossplay, hostRounds, hostRoundsCompleted and cliPreflight. The client is closed in every outcome, then the world is moved into the evidence (hostWorldEvidence). After a failure an attached client, or an owned client that did not stop, may still host the world; it is left in place and named in hostWorldLeftInPlace. Remove it once that client has left it. Every hosted run journals that placement before it is made, with the fixture's world UID, and a fixture already named in the client's worlds is refused before anything is copied. After a killed run (valheim-test start --hold, or rounds you drive), valheim-test env status lists the world and valheim-test env recover moves it out of the client's worlds into the run's folder (<runId>/host-world in the arm's evidence or the rounds' output), but only when every metadata file of an entry named for the world there (an <entry>/_main.<n>.fwl2 or <entry>.fwl) states the fixture's world UID. A same-named world with another UID, or with no readable metadata, stays in place.
A no-game test keeps its hosted world in a private directory with FakeClientDataDirectory. Hosting clients in a session are on the GameSessions page.