Skip to content

Sessions

A session is one client’s connection to one world. It has a beginning that is allowed to take time, a middle where a map can change underneath it, and an end that always names its own reason.

A client is always in exactly one of two resting places: playing on one server, or disconnected. Everything in between is an attempt that resolves into one of the two — never a half-live state that a late packet can revive.

  • connect <host>[:port] is terminal for whatever came before it. A live session is detached and an attempt still in flight is canceled, both before the new attempt starts, so the connection being replaced can never knock over the one replacing it. The old connection’s disconnect arrives later, from the transport, and is deliberately swallowed rather than reported as a drop.
  • connect with no argument retries the last endpoint — the server a drop lost, not a default one. Before any connect has been made it falls back to this machine on the default port.
  • A target that cannot be reached (refused, unreachable, timed out) produces exactly one console line naming it — connect: could not reach host:port (reason) — and settles in Disconnected, with the outcome dialog over the menu it lands on. No retry loop, no per-message warnings, and no re-admission to the server that was left.
  • disconnect announces the departure once, at the moment it is decided, and also cancels an attempt that has not completed — an explicit disconnect ends interest in the endpoint entirely. It lands the player at the main menu, and so does any other way a session ends: a server that drops or closes, and a connect that could not be completed.

Every ending is recorded once, at the place it is recognized — in the client, as a SessionEnd carrying the kind, the address that was dialed and the protocol numbers where they are known — and handed to the menu through the ordinary session transition. Nothing downstream re-derives a reason by reading the console line back.

An ending nobody asked for raises a dialog over the main menu, in the same family as every other ask the engine makes, the moment the client is back at the menu. It is a title, a message and a detail line:

EndingMessageDetail
Could not reach the serverThe server did not answer.Tried host:port.
Timed outThe server took too long to answer.Tried host:port.
Protocol mismatchThis server runs a different version of the game.This server speaks protocol 80; this client speaks 1.
Closed by the serverThe server ended the session.The server’s own reason verbatim, or The server closed the connection. when the wire carried none.
Map transfer failedThe map did not arrive correctly.What arrived did not match what the server described. Connecting again usually fixes it.
Unreadable messageThe server sent something this client could not read.The session was ended to keep this client in a known state.

The first two carry the title Could not connect; the rest carry Disconnected.

A quiet ending shows nothing. A typed disconnect, the pause menu’s Disconnect and quitting to the menu are things the player just did, so there is no report to make.

Retry is offered only where a second attempt could help — a dial that was refused or that ran out the clock, to an address the client still knows — and it takes the very same connect path the console’s connect takes. A version disagreement is deliberately not retryable: dialing the same server again produces the same disagreement.

The console line is unchanged. The dialog is a second surface for the same fact, not a replacement for it.

The iOS shell raises the same dialog off the same model through its own menu. It offers no Retry, because joining there runs through the shell’s own join request rather than a bare connect.

Four steps, in one fixed order, and the last one is the client saying who it is:

#MessageDirectionCarries
1connectRequest (1)Client → serverThe client’s protocol version and the 16-byte hash of its wire schema, and nothing else.
1aschemaHash (44)Server → clientThe server’s schema hash, and whether the server needs the client’s schema.
1bschemaText (45)BothOnly when the two hashes differ: the client’s packed schema if the server asked for it, then the server’s if the client asked.
2serverInfo (2)Server → clientThe server’s version, tick rate, world id and name, and the current map as (name, palletHash, closureHash).
3clientInfo (20)Client → serverThe player’s display name, packed 0xRRGGBB UI seed color, and the avatar barcode they wear — empty for none.
4clientReady (4)Client → server”The map is loaded, spawn me” — answered with spawnAck (5).
  • The opening message is small and fixed for good. It is the one a server accepts from a peer it knows nothing about, so it carries nothing a mismatched or hostile build could make the server allocate against. Every build sends its version first, so a server can always say which number it was offered. A version mismatch is settled at step 1, before a player’s name has crossed the wire in either direction.
  • A matching build joins on two hashes. When the client’s schema hash is the server’s own, schemaHash says so and the server goes straight on to serverInfo: the exchange costs 16 bytes each way and no round trip. When they differ, the server waits for schemaText, and each side sends its full schema only if the other has never seen it this process. The full schema is about 5 KB, deflated from 18 KB of text, and it crosses once per process: the next client of that build joins on the hashes alone. Both sides log which it was: schema: alice writes this server's schema 3fa9c1d2 (hash only, 16 bytes each way), or ... reading it by name (full schema: 5112 bytes sent, 5112 received, beside 16-byte hashes).
  • clientInfo is what admits the session. Receiving it is the moment the server learns the name, resolves the identity it keys admin standing on, and moves the peer to loading the map. Before it lands the server knows nothing about the player — so nothing that reads a name, an identity or an operator standing is accepted from, or sent to, a peer still in the handshake.
  • The name is client.name, and it is never blank. Unset, it defaults to the machine’s host name (letters, digits, - and _, at most 32 characters), or to the platform and a short hex id, such as ios-3f2a, where the host name says nothing (a phone reports localhost). Names are display-only and duplicates are allowed, so the server’s log lines add the peer’s endpoint to every name they print: peer disconnected (steamdeck (192.168.8.198:51234)): Timeout.
  • The order is the guarantee. Because clientInfo goes out on the reliable-ordered channel the instant serverInfo arrives, the server knows who a peer is before it hears another word from it — a map request, a forwarded console line, a ready.

A client whose schema differs from the server’s joins anyway, and is read through a plan that pairs fields by name. Once it has spawned, the server sends it one private chat line, from the server, and nobody else sees it:

[server] Protocol mismatch: yours 3fa9c1d2, server 8b0e4411

That is the whole of it: no dialog, no refusal, the mismatch and the two schemas as their short hashes. It is sent once a session, not once a map.

The schema is also the list of what that client can do. The server never sends a client a message its schema does not list — it would only be dropped — and logs the first one it holds back: not sending hitEvent to alice: its schema does not list it. A feature that has an older fallback asks the session first: PlayerSession.Reads(MessageId) for a message, PlayerSession.Knows(shape, field) for a field. Both are true for every session of a matching build. The quiet map rebuild is the first to: a room where any client’s schema lacks mapRevision gets the full reload instead.

A build from before the schema exchange is refused. Protocol 80 and earlier had no schema, so a server on protocol 1 refuses it with both numbers in the reason and a log line that says what happened: refused alice (192.168.8.20:50112): it speaks protocol 80, a build from before the schema exchange; this server speaks 1, and that client has to be updated to join. The old client’s own dialog reads This server speaks protocol 1; this client speaks 80, as if the server were the old one. That text is baked into builds that shipped before the reset, and nothing on their side can change it.

Right after a session is admitted, the server sends it one reliable editorAuthority (26) carrying two raw facts and no verdict: whether world.cheats is on, and whether this identity is an operator. It is re-sent whenever either can have changed — the operator list is refreshed, or world.cheats moves (that preference has no change event, so the server samples it once per poll and broadcasts only on the edge).

The facts rather than the answer, because the client re-runs the server’s own gate on them. A client that had to be told “you may not delete” would be a second copy of the policy with a network hop in the middle of it, free to drift from the one the console applies to the line it actually sends. This is what lets the level editor draw every command it may not run as dead before you press it, instead of accepting the press and printing a refusal. Nothing is trusted to the client by saying it: the server still gates every command it receives, exactly as before. Adding the message bumped NetProtocol.Version 55 → 56.

Authority says what a session may do to the world. It says nothing about whether this machine can write the map’s authored .dh-map back — a client connected from another computer has no file to write, and two clients that both have the pallet checked out would race one file. So the room elects a single holder, over one reliable editorSourceHolder (27) that runs both ways under one id, the direction disambiguating the body: client → server is a declaration (I can, or cannot, resolve this map’s source here, naming the map), server → client is the result (is it you, and if not, whose name to say).

The server elects the first declarer and announces only on change, releasing on withdrawal, on disconnect and on a map load, and re-electing whoever is left. The holder’s display name rides the message because no player roster replicates — a client told only “not you” would have nobody to point at. Adding the message bumped NetProtocol.Version 57 → 58.

The election decides who WRITES, not who may ask. Save is forwarded: any session with authority presses it, editorSaveRequest (37) carries the ask to the server and on to the elected holder, and editorSaveResult (38) carries the holder’s own sentence back to that one asker. A second ask while a save is out is refused, and a holder that drops mid-save answers the waiting asker rather than leaving it hanging. Both ids are appended, so nothing about them moved the version.

The box belongs to a map arriving, not to a join. Every way a map becomes the map the player is standing in goes through one frame-sliced job and shows this card: a remote join, the local world this process starts for itself, a typed map, a server changing map under a connected client, and the pallet watcher noticing that the loaded map was rebuilt. There is no state in which the engine is busy and silent, and no path on which a map is swapped inside a single frame.

The one deliberate exception is the two offscreen hosts — --render and --benchmark. They have no window, no frame loop to slice against and no surface to paint a box on, and both exist to produce a deterministic result, so they open their map synchronously through OfflineHost.OpenMap and always will. The engine’s very first map is the other: it is opened during host construction, before the window presents a frame, so there is nothing yet to paint over.

It is one Halcyon card, centered on the screen for a join or a start. A map switch inside a session docks it in the bottom-right corner instead, inset by client.overlay.toastMargin like the toasts, so it never covers the world the player is still looking at. It carries a title, a step counter, one line naming the current step, a thin progress bar and a footer with a Cancel button.

The same card also carries a device rebuild. Tearing the Vulkan device objects down and standing them back up takes seconds during which nothing can be drawn, so the request never runs in the frame that made it: the frame that asked builds this card with the reason on its step line, records, submits and presents, and the rebuild runs at the top of the next frame, before acquire. The window keeps that last presented image while the device is gone, which is the whole point — the notice is on the monitor for the entire outage rather than the frame before it. The bar is indeterminate and the button slot is empty, because a rebuild in flight cannot be canceled. The requester supplies the reason (switching to VR, leaving VR, rebuilding the graphics device, and rebuilding the graphics device, pass 2 of 5 when gpu.rebuild N cycles), so the card knows nothing about VR; the rule is on the rebuild, not on its callers, and any future caller gets the notice for free. A map load already on the card yields the slot and gets its own phase back when the rebuild ends, the card clears on the first frame after the rebuild completed rather than during it, and a rebuild that fails leaves its message on the card with the Close label. A window resize is a swapchain recreate rather than a device rebuild and gets no notice.

One line, not a log. The box shows the step it is on and nothing else — a scrolling history is a thing to read, and this is a thing to glance at. The title is the endpoint until the server names its map, at which point the map takes the title and the endpoint drops to the footer; neither line ever repeats the other. A switch knows its map from the first frame, so its title is named immediately.

A map is named, not keyed. The box is handed a barcode and prints the map’s NAME: the pallet prefix runs faded in front of it and the .dh-map extension is dropped entirely, which is the same division the editor bar’s map readout makes. AssetNaming is the one place that split lives, so a card and a bar naming the same map cannot spell it two ways.

The step line wraps. A failure quotes a path and a reason and the two together never fit on one line, so the detail run is the one run on the card that breaks across lines, growing the card down to a bounded number of lines and ellipsizing past it. The title stays on one line: a name that wrapped would push the bar around every time a map with a long name came up.

The counter is honest or absent. A cache hit takes two fewer steps than a download does, so until serverInfo settles that question there is no total to print and none is printed. Once the plan is resolved it is never re-planned: a total a player read a second ago stays the total, whatever a later caller believes. The two arrivals that touch no wire settle their plan on the frame they open.

ArrivalStepsWhat it drops
Join that downloads11Nothing.
Join that hash-skips9The download and the verify.
Local start7, or 8 when it builds the gameThe dial, the reply and the cache check as well, and it adds the server start in front.
Map switch4All of the above, plus the tail — a client already playing announces no readiness and waits for no first snapshot.

The switch’s plan is short because it is honest: a typed map downloads nothing and shakes nobody’s hand, and the snapshots never stopped arriving, so it is loading {map} → building collision hulls → uploading textures to VRAM → preparing the renderer and no more. Its footer says switching the map. A rebuild of the map already standing opens no box at all: it is a revision, brought in behind play and said in a toast.

#LineProgress
1connecting to {host}:{port}Indeterminate — the socket reports nothing until it binds.
2waiting for a reply from the serverIndeterminate.
3checking the server map against the cacheIndeterminate.
4downloading the map ({done} of {total} MB), or downloading pallet {pallet} (…) for a pallet the map depends onReal — bytes received against bytes advertised.
5verifying the map's content hashIndeterminate.
6loading {map}Indeterminate — one parse, not a divisible one.
7building collision hulls ({done} of {total})Real — colliders cooked against colliders planned. Reads building the collision mesh when the map cooks its own triangles instead, which is reported as a single step rather than counted.
8uploading textures to VRAM ({done} of {total})Real — the merged mesh, each material, the lightmap and the brushes, counted as the scene comes up.
9preparing the rendererIndeterminate.
10telling the server we are readyIndeterminate.
11waiting for the first snapshotIndeterminate.

A local start skips steps 1–5. In their place it runs starting the local server, then starting the local world before the map steps. A switch or a map hot reload runs steps 6–9 only.

Start Game shows the box on the frame you clicked it

Section titled “Start Game shows the box on the frame you clicked it”

Nothing about starting a world happens inside the click. Start Game and Quick Join both enter the loading state and return; the frame that carries the click is the frame the box is painted on, because ClientUiState is polled once a frame and a handler that built a server never gave the frame back. The client remembers the start it owes, presents that frame, and only then hands the whole construction — preferences, the console and autoexec, the spawnable registry, the game module, the boot map, the net server — to a worker. The frame loop reads the worker’s furthest step, feeds it to the loading model, and dials loopback only once the world is standing. Until then there is no connection at all, which the session reconciler would otherwise read as a session that ended, so a start in flight holds the main menu off.

This is what the box is for on this path, and it is why the server-start steps live in the same ClientLoadingModel plan as the map steps rather than in a second progress surface: one card narrates the whole arrival, whoever is building the world.

A start that fails reports the worker’s message through the same card and returns to the main menu with nothing running, exactly as a failed connect does.

The tail step names what is actually sent. Under protocol 29 clientInfo rides out the instant serverInfo arrives, long before any map is loaded — so the last thing the client says before snapshots is clientReady, and the line says so.

One bar for the whole join, weighted by rough step cost and monotonically non-decreasing: a late poll carrying fewer bytes, or a step report arriving out of order, cannot move it backward, because a reading that retreats reads as work being undone and nothing in a join ever is. Where nothing is measurable it sweeps instead of sitting at a number it made up.

A failure that ends the session clears the box and reports it at the menu. A join that never lands does not leave the bar frozen behind a suppressed menu: the session teardown takes the box down, puts the main menu up and raises the outcome dialog over it, which says the same thing in words a player can act on. A failure that does not end the session — a map that would not load under a world already running — still freezes the box where it stood, with the reason verbatim on the step line and Close as the button’s verb.

Cancel over a join is the ordinary disconnect. There is one teardown path, and Cancel, the pause menu’s Disconnect and a typed disconnect all take it. ESC over the box opens the ordinary pause menu, and the box keeps painting underneath it.

Cancel over a switch cancels the switch, not the session. There is a world here already and it is still perfectly good, so the job is dropped at the boundary it reached and the player stays in the map they were standing in — the same thing that happens to the map a canceled join was going to replace. The server is never asked to switch, because it is only ever asked from the load’s commit.

A switch is not a loading state. ClientUiState.Loading is fed from the box’s visibility for arrivals that have no world behind them. A mid-session switch has one — the old map, still meshed, right up to the commit frame — so the cursor stays locked, the crosshair stays up, gameplay input stays live and the vignette does not ramp. Freeing the pointer and dimming the world on every pallet rebuild would make the map-authoring loop worse than the freeze it replaced. ESC still reaches the pointer if the box’s Cancel is wanted.

What stands still is the pawn, not the input. The moment a switch’s collision is swapped in under the local pawn, the client stops simulating it (the same hold a server-pushed switch takes) until the server confirms the map, so it does not fall through a map it was never placed in. On the server, a change of map respawns every pawn at the new spawn with an ArrivalHold marker and the Frozen flag; the first input its owner’s client sends in the new map ends the hold. The hold composes with the editor’s freeze: a pawn that is also editing stays frozen when the hold ends, and one still held stays frozen when the editor is left. A switch that fails does take the pointer back, because its box then holds a Close button.

The asset table survives a switch. The server’s table only grows over a session, so the client keeps the one it has when a new map is advertised and replaces it when the new baseline’s table lands; worn avatars never fall back to the placeholder box in between. A move to another world still drops it, since a table belongs to its world.

The session begins the instant a connect is asked for, so the interaction state says Playing while the frame behind the box is still empty. Everything that belongs to a world rather than to the program has to know the difference, and one flag is what tells it: ClientUiState.Loading, set from the box’s own visibility once a frame.

  • The cursor is free and visible, exactly as at the main menu and the pause menu.
  • Gameplay input is gated, because freeing the pointer without gating the look is what leaves mouse-look warping a cursor somebody is trying to use.
  • No crosshair. There is nothing to aim at.
  • No speedometer. The player-facing HUD asks ClientUiState.HudVisible, which is there is a world and nothing more — so a join draws no speed readout, exactly as the main menu draws none. It is the only other place the HUD and the reticle agree: everywhere a world exists, the speedometer stays and the reticle leaves.
  • No world-anchored developer surface. The axis gizmo, the position readout and the entity gizmos are all off — InSession means there is a world, and during a join there is not one yet.
  • No lens vignette and no pause scrim, for the same reason the main menu has neither: shading the corners of an empty frame is a dark border around a loading box. The player’s client.render.vignette is never written — only the session ramp that scales it moves, so the setting comes back untouched with the world.

Cursor lock is a property of the state, not an act performed at a transition. There is exactly one place that decides it — ClientUiState.CursorVisible — and the host asks it once a frame and applies the answer. That is what makes pressing ESC over a join and then resuming leave the pointer free: the loading term is still true on the other side of the round trip, so there is nothing for a resume to undo. The link set the menu shows is a separate question: a join in flight offers Resume and Disconnect, because there is a session to leave.

The one thing leaving the loading state does is ask the host to absorb an input frame, the same way a window drag ending does — the pointer moved while it was free, and none of that motion is a look delta.

A map load is a frame-sliced state machine ticked from the same frame path as everything else — Parse → Collision → VRAM upload → Commit. Slicing is what lets the box paint at all: a synchronous load renders one frame at the start and the next when it is over. There is exactly one of these jobs, and every arrival above builds one, which is what stops the box from being a decoration on the join path.

The two stages that are neither divisible nor short hand off to a worker and yield the frame back until it lands — the pallet read plus collision cook in Parse, and the physics backend’s one indivisible weld-and-BVH call in Collision. Slicing alone cannot shorten either; both are single calls that take seconds on a large map. The state machine is still what the frame path ticks, and the box still paints throughout.

VRAM upload cannot hand off that way — Vulkan work belongs to the thread that owns the device — so it is budgeted instead: it does a few milliseconds of meshing and uploading per frame and gives the thread back.

  • Parse opens the pallet, builds the collision data and starts a resumable prediction rebuild — on a worker, and consulting the on-disk collision cook cache, which is what turns a multi-second extraction into a read on every load after the first.
  • Collision advances that rebuild by client.load.hullsPerFrame colliders per frame (default 64), reporting real progress as it goes. In autoCollision: "mesh" mode there are no baked per-node colliders to count — the map cooks its own triangles, one body per source object — so this stage is a single worker-backed step rather than a counted sweep.
  • VRAM upload hands the pallet to the renderer and meshes the render scene by client.load.vramBudgetMs milliseconds per frame (default 8), reporting the merged mesh, each material, the lightmap and the brushes as it goes. This was one blocking call until it wasn’t: on a large map the whole scene — every vertex buffer, every material’s textures, the lightmap — came up in a single frame, and the client thread was gone for as long as it took. The grain is one texture channel, so the budget is a target rather than a ceiling. The swap is still a single step: the new scene is built entire beside the live one and becomes visible only on the slice that finishes it, so every frame in between draws the map the player is already standing in.
  • A reload uploads only what changed. The meshes, tables and textures the GPU already holds are kept across the switch and bound again by content identity, so rebuilding a map with nothing changed uploads 0 bytes, and a one-material edit uploads that material’s texture — see GPU resources stay resident across map loads. Nothing is destroyed before the commit: both generations hold the shared resources across the swap. The loading box still narrates the reload; the quiet swap without it is a later stage.
  • Commit publishes the map to the host and answers the server.

Cancel is checked between slices, never inside one. The job holds no live host state until Commit, so abandoning it at any boundary releases the staged prediction world, the half-built render scene and the open pallet, and leaves the map on screen untouched. Every loading frame declares itself to the frame-stall guard, so the delta the next frame is handed is clamped rather than delivered whole.

A second map supersedes the first rather than racing it. A map typed while a load is running, or a rebuild landing mid-load, cancels the job in flight and starts a new one — the half-built world it was carrying describes a map nobody is going to be standing in. What the superseded job was owed survives it: a join still waiting in loadingMap is readied up by whichever load actually lands, and anything gated on a load ending (the hot-reload watcher’s own gate) is released on the cancel rather than left shut. A switch landing inside a join’s box continues that box’s story instead of restarting it halfway.

client.net.connectTimeout (default 10 seconds) bounds an attempt that never answers. It fires as the ordinary could-not-reach disconnect — the same console line, the same landing at the menu — rather than as a failure mode of its own.

Every asynchronous load logs a begin and a done line

Section titled “Every asynchronous load logs a begin and a done line”

A map load is one of several load kinds that write the same two sentences, so a lag spike can be told apart from a load without guessing: a dh.object spawn, an avatar (worn or previewed) and a map switch all open with load <kind> <barcode> begin and close with either load <kind> <barcode> done in <N> ms or, on failure, load <kind> <barcode> failed after <N> ms: <reason>. <kind> is object, avatar or map; <barcode> is the definition’s colon-form barcode for an object or avatar, and the map’s name for a map. LoadTiming is the one helper every path calls — nobody hand-rolls a fifth wording.

  • These lines need no console filter change to see. They log through the same categories the console already mirrors in full (render for object/avatar loads, client for map loads), so they appear the moment they are written.
  • A done line carries a stage breakdown in parentheses only where the path already measures its own stages. An object or avatar’s done line breaks the total into off-thread prepare time versus the frame-sliced residency transfer that follows it, since a load only feels different from a hitch when you can see which half was which. A map load’s done line carries only whether the content came off the wire, because the phase-by-phase timeline — parse, collision, VRAM upload — logs its own richer summary line immediately after, and folding the same numbers into the done line would say the same thing twice.
  • A twenty-object join produces twenty pairs of lines, not one. There is no verbosity gate on any of these — each load is one load, and suppressing a line because many happened at once would hide exactly the case worth seeing.

Every pallet a player waits on is on screen. The loading box narrates a map arriving; everything else that moves — other players’ avatar pallets coming down, this client’s own avatar going up, a map a live server switches to — and every delivered avatar still loading is a row in the transfer list, a stack of small cards in the top-right corner.

RowLabelDetailBar
A map, or a pallet it depends on, downloading in a live sessiondownload{done} of {total} MBReal
Another player’s avatar downloadingdownload{done} of {total} MB, then - {pallet} when the member moving is not the avatar’s own palletReal, per member
This client’s avatar uploading over the bulk channelupload{done} of {total} MBReal
A transfer asked for and not moving yet (a queued avatar, a chunk request)downloadwaitingIndeterminate
A delivered avatar the object cache is loadingloadingnoneIndeterminate
Any of these that ended without its palletas abovethe reason, in redFrozen gray
  • One model, every shell. ClientTransferModel (Engine.Client) reads NetClient.CollectTransfers and the TransferFailed and AvatarDelivered events, asks the shell’s object cache where each delivered avatar stands (ObjectLoadState), and resolves the rows; the desktop host and the mobile NetworkSession each construct one over their client, tick it once a frame and push its snapshot into HalcyonSettings.Transfers. Nothing about what is listed is decided by a host.
  • The join is the box’s. While a join waits on its map, the map’s own pallets and its dependencies stay off the list, because the loading box is already narrating that download — and names a dependency by its pallet, downloading pallet {pallet} ({done} of {total} MB), instead of calling it the map.
  • A row appears once its transfer has run for client.hud.transferShowDelay (0.4 s) so the transfers over in a frame never flash a card, and goes the frame the transfer ends. A failure appears at once and stays for client.hud.transferFailureLinger (8 s). A delivered avatar’s loading row lasts while the cache is loading it, or client.hud.transferLoadGrace (5 s) if nothing ever asks for it. Past client.hud.transferRows (4) the rest are folded into {n} more. client.hud.transfers turns the list off; client.hud.transferWidth sizes it.
  • It is a screen under the menu, like the HUD it sits beside: a pause scrim dims it with the world, the editor’s chrome sits under it, and it starts below the menu’s build identity so the two never overlap. The cards are the loading box’s own palette, dialog bezel and ProgressBar — the same bar, not a second one.
  • Not listed: an avatar upload that falls back to reliable chunks. Its chunks are handed to the transport in one burst and nothing reports them landing, so there is no honest reading to show.

A session ending takes the world with it. The map dithers away over about a second and is then genuinely released — the main menu is left over the sky, not floating above a world nobody is standing in.

  • Every session end runs it, because it is wired to the one session teardown rather than to any particular cause: a typed disconnect, the menu’s Disconnect, a server that dropped or shut down, a kick, a timeout, and a connect that never landed all dissolve identically.
  • It is a dither, not a fade. The opaque shader discards pixels against a screen-space threshold at the top of main, so the surviving pixels are lit exactly as they were at rest and the vanished ones are simply not there — the world never dims, goes unlit, or fades toward black on its way out. The threshold is the same interleaved gradient noise every other dither in the renderer uses (ditherDissolved in dither.glsl), so there is one dither aesthetic across the engine rather than two, and because it is a pure function of the pixel coordinate a pixel dies once and stays dead — which is what stops the temporal resolve from smearing the dissolve back into a soft alpha fade.
  • Only the map dissolves. The sky, the developer surfaces and the UI sit outside the opaque pass, which is what leaves the menu over a sky rather than over a hole.
  • The pause treatment leaves with it. The menu’s full-viewport dim and the lens vignette belong to a session, so they ramp off alongside the dissolve rather than popping the instant the session ends — client.ui.sessionFade is how long that takes, defaulted to client.render.worldDissolve so the screen clears on one clock, and 0 there is instant. The pause menu and the main menu are the same screen; the scrim, the vignette and the world-anchored developer gizmos are what separates them.
  • client.render.worldDissolve is how long it takes, in seconds (default 1, max 10). It applies live through one frame-uniform lane, so a change is visible on the very next disconnect with nothing rebuilt. 0 is a real off switch: the world is released on the same frame the session ends, with no animation at all.
  • The release is genuine. The render scene goes whole — every vertex and index buffer, every texture its materials uploaded, the lightmap, the irradiance volume’s storage buffer, the collider and hull wire meshes, and the open pallet handles the map was read from — along with the client’s prediction physics world and its cooked collision, and this host’s memory of what was loaded (including the content hash, so the next join re-meshes from the server’s advert instead of hash-skipping onto a map the client no longer has).
  • The set-1 descriptor slots go back too. The scene hands every per-material descriptor it asked for back to the texture registry, rather than leaving it to whichever texture happens to be destroyed — see who retires a descriptor set, and when. Without that, a material with no authored texture channels would leak one slot of a finite pool on every single map load, and a long session of server-hopping would eventually run it dry.
  • A new session always wins, immediately. Nothing about a connect waits for an old world to finish leaving: the dial happens at once, and the session’s begin edge — plus any map actually meshed — abandons the dissolve where it stands. The map still loaded snaps back whole rather than freezing half eaten, and the abandoned dissolve can never come back later and unload the map that replaced it. Reconnecting to the same map keeps the scene it already has, exactly as a mid-session switch does.
  • A disconnect with the world already gone does nothing — no dither, no release, no console noise. There is nothing to dissolve.

Entities fade out as a camera closes on them

Section titled “Entities fade out as a camera closes on them”

A camera must never end up inside what it is looking at. CameraProximityFade is the general capability that prevents it: a component carrying two distances in meters, which any system may place on any renderable entity. Inside Near the entity is completely invisible, and from there it ramps back to fully opaque at Far.

  • It is a capability, not a feature of one camera. The level editor’s detached free camera puts it on your own pawn so the camera cannot fly through your head, and takes it off on exit; a third-person camera gets the same behavior later by placing the same component with its own numbers. Nothing in the component knows about an editor, a preference or a frame — whoever places it supplies the meters, which is what keeps one caller’s tuning out of everyone else’s way.
  • It is the same dither as the world dissolve, and deliberately so: the coverage rides down to the fragment stage as a per-draw dissolve and goes through the same ditherDissolved screen door, so a half-faded body is still a plain opaque draw that writes depth normally, with no alpha blending and no sorting anywhere. A pixel is gone if either dissolve has taken it.
  • The ramp is a smoothstep, so neither end is a moment you can see — a linear ramp under a dither reads as two distinct events, one at each distance.
  • A fully faded entity is not drawn at all. The draw is skipped at submission rather than discarded per pixel, which also keeps it out of the shadow pass. A partially faded one still casts its whole shadow; the fade is about what the camera is inside of, and its shadow is not on the lens.
  • An unconfigured component fades nothing. Both distances zero resolves to fully opaque everywhere, so an entity that acquires the component before anyone configures it never blinks out.

Which server a client is on is a session decision, not a launch decision. An all-in-one client — the default windowed game, which runs its own embedded server — can connect somewhere.else:27015 and go, with no relaunch; from the main menu it starts a local world again. --connect remains a launch convenience for joining a server outright, and nothing more.

  • One path handles every case. connect = leave whatever you are on, then join the named endpoint. Leaving a remote server closes a socket; leaving the embedded server shuts it down whole — universe, entities, physics and tick thread — because a world nobody is in must not go on simulating. Local → remote, remote → remote and remote → local are the same operation, repeatable for as long as the process lives.
  • The local world is an endpoint like any other. It is named by the reserved port 0 (Conventions.EmbeddedPort); every real UDP port is 1–65535, so there is no ambiguity. A connect to it boots a fresh embedded server, which is why coming back from a remote server starts a clean local world rather than resuming a stale one.
  • Start Game always starts your own world. The menu’s Start Game runs this machine’s embedded server on the map the launch window picked, however many servers the client visited first; the endpoint a previous connect left behind never redirects it. Joining someone else’s server is the Connect path, which names an address (connect with no argument still retries the last endpoint, deliberately and explicitly). The one exception is a client launched with --connect, which hosts no local world at all: its Start Game can only mean the server the launch named.
  • A --connect launch meshes no scene of its own. Like a plain launch to the menu, and unlike --map, it opens no world at boot: the view is sky until the server’s map lands, rather than a flash of whichever map the bundled pallet happens to list first.
  • A connect that fails leaves nothing running. The old session is given up to make the attempt — that is what “terminal for whatever came before it” means — so a failed connect lands at the main menu with the one could not reach line. Start Game builds a fresh local world from there: the fallback is a menu, never a broken process.
  • Nothing crosses between sessions. The replica store, local pawn, map advertisement, command manifest, scene-edit revision, prediction and chat feed are all cleared the instant a connect is issued, so a stale entity or map hash from the previous server cannot bleed into the next one. The map on screen is re-meshed whenever the new server’s content hash differs from what is loaded.
  • map follows the server. Switching maps means switching the server’s map, so it is offered only while this client is the one hosting. Having left for a remote server, map resolves the barcode and says the map is the server’s — the same answer a --connect client has always given — and a pallet recompiling no longer hot-reloads anything.

Under the hood the client holds one transport for its whole life — a SwitchableClientTransport — whose underlying transport is replaced on every connect by an endpoint resolver (ClientSessionRouter), which owns the embedded server while the answer is “this machine’s own world”. The session above it cannot tell the floor moved: a disconnect the client asked for is still delivered even when the transport that owed it is already gone, and a resolver that cannot produce a transport at all surfaces as that same one-line connect failure rather than an exception out of a console command.

Player numbers are roster slots, not a running count. A number is freed when its pawn despawns and reclaimed by the same identity on a rejoin, so reconnecting keeps the number you had instead of climbing the roster every cycle. Numbers are unique among the players currently spawned; a number read off a stale pawn names whoever holds the slot now, so ownership is always resolved through the live pawn.

players reports the room, from the dedicated server’s stdin or, forwarded, from an operator’s console in game. One header line — players: 3/4 connected, or players: 3 connected (no player cap) where nothing caps the server — then one line per session, which includes the ones still handshaking or loading and therefore owning no pawn:

players: 3/4 connected
#0 alice identity=local:alice addr=loopback state=playing pawn=1:0 ping=0ms overrides=0 op
#1 bobbington identity=local:bobbington addr=127.0.0.1:7777 state=playing pawn=2:0 ping=12ms overrides=2
#- carol identity=local:carol addr=127.0.0.1:52310 state=loading pawn=- ping=31ms

Everything on the line is something the session or the transport already knew: the roster slot, the claimed display name, the server-resolved identity, the remote endpoint, the handshake state (connecting, handshaking, loading, playing), the pawn’s wire id, the transport’s round trip where it measures one, that player’s override count, and op for an operator. A column with nothing behind it prints - rather than a zero. It is authority-gated like say and refused with the same sentence every world-authoritative family uses — a guest is told no before a line is composed, because a roster is a list of other people’s addresses. The cap in the header is server.maxPlayers, read live, and both server shapes quote one: on a listen server the count includes the local host, on a dedicated server it is the remote sessions alone. players: 3 connected (no player cap) is what a server wired with no capacity at all reports, which neither shipped composition is. Someone refused for capacity is not in this listing: the gate runs before the session exists, and that client is told server is full (4 players) instead.

Chat is server-authoritative, on the same principle as everything else that several people have to agree about. A client sends only the text it typed (clientChat); the server decides whether it may be said, stamps who said it and which roster slot they hold, and reliably broadcasts one serverChat to everyone. A client is never trusted with its own name, so nobody can speak as somebody else — or as the server. The two message ids bumped NetProtocol.Version 25 → 26.

That single broadcast message carries four kinds of line, which is exactly why they can never disagree between two screens:

KindWhere it comes from
playerA line somebody typed.
serverThe say console command.
joinGenerated server-side when a session finishes loading and starts playing.
leaveGenerated server-side when a playing session disconnects.

Join and leave are roster events, not client courtesy messages. They are emitted from the same slot bookkeeping described above, so a rejoin reads as one ordinary join into the reclaimed slot rather than a ghost duplicate, and a peer that drops during the handshake or a map transfer — one that never reached playing — is never announced at all, in either direction. A leaver is out of the session map before the leave goes out, so the notice reaches everyone still connected and never the person who left.

What the server enforces on arrival, regardless of what the sender already checked:

  • Length. Capped at ChatLimits.MaxMessageChars (200) characters, and the sender’s display name at 32. Truncation is surrogate-safe, so a line ending in an emoji arrives whole or not at all rather than as a replacement glyph.
  • Control characters are stripped, not escaped — a newline would let a player forge a second line in the feed and in the server’s log, and an ANSI escape would reach past the renderer into whatever terminal is tailing that log.
  • Nothing empty gets through. A line that is only whitespace or only control characters is dropped rather than broadcast as a blank row.
  • Flood control is a per-session token bucket: 4 lines back-to-back, refilling at 0.75 lines per second. Bursty rather than flat on purpose — finishing a thought across three quick lines is a thing people do, sending one line every second forever is a thing scripts do. Idle time never banks more than the burst, and one peer flooding never spends anyone else’s tokens.

say <message> broadcasts as the server. It is authority-gated rather than hidden: an operator can use it from a remote console, and a plain client that reaches it is refused inside. Everything else applies — the line is sanitized and capped exactly like a player’s, and say on a server nobody is connected to says so rather than pretending it went somewhere.

Chat has its own log channel, chat, on both ends: the server logs every line it broadcasts, and a client logs every line it receives, both through ServerChat.ToLogLine() so the log and the on-screen feed word the same event identically. Because it is an ordinary category it shows up in the console’s logger filters, and a headless server — which has no feed to draw — still has a complete chat transcript in its log.

A client tells the server its appearance, as a packed 0xRRGGBB on clientInfo — the last step of the connection sequence — and again on clientAppearance whenever the seed preference or the avatar changes mid-session. The resolved triple rides the wire rather than the preset’s ordinal, so a server that predates a preset added later still stores a color it can use. Today the server only stores it on the session and replicates nothing — it exists so that a name in the feed can eventually be drawn in the color its owner picked, which is a decision that has to be made server-side or two screens will disagree about it. The pair bumped NetProtocol.Version 26 → 27; carrying the avatar alongside the color — and renaming clientThemeColor to clientAppearance so a color change and an avatar change cannot race into a half-applied appearance — bumped it 72 → 73.

The client side is a Halcyon surface up the left of the screen, resting a third of the viewport clear of the bottom edge rather than hugging it — a column that breathes, not a status bar. Newest line at the bottom, above where a HUD would go. Lines hold at full opacity and then fade out; opening the prompt reveals the whole scrollback again and holds it there while you type.

The closed feed and the open prompt are ONE tree, and opening moves nothing. The input row is mounted and reserving its height even while chat is closed (an invisible Layer still lays its child out), the surface is anchored by its bottom edge, and the scrollback is pinned to its end — so the lines you were reading during play stay at exactly the pixels they were at, and opening only adds the field below them, the plate behind them, the rest of the history above them, and the keyboard. Opening is what the flag changes; layout is not.

There is no open-but-unfocused state. Clicking away dismisses chat outright rather than leaving a prompt up that nothing can reach, and Escape always closes it. Either way the draft is kept and the caret comes back at its end — sending is the only thing that empties the field. Closing plays the entrance in reverse rather than cutting.

History is a bounded ring of 256 lines on the client — presentation state with no authority and no entity that owns it, so a queue is the honest model rather than a component. Fade timing runs off a monotonic client clock, not a server timestamp: how long a line has been sitting in front of you is the only thing a fade can honestly mean, and a server stamp would make a line that arrived late fade early or arrive already gone.

Everything about how it looks and how long it lingers is a client.chat.* preference, applied on the next frame: holdSeconds (12), fadeSeconds (1.5), lines (6 in the passive feed), scrollbackLines (128), scrollbackHeight (240), width (560), leftInset (24), bottomFraction (0.34 — the gap to the bottom edge as a fraction of viewport height, so the column sits in the same place at every resolution instead of drifting toward the bottom as the screen gets taller), opacity (0.55) and timestamps (off). These are local taste and are never networked — what may be said and how often is the server’s business and lives where no console line can reach it.

Set server.chat.forward.url and the server posts the chat lines it broadcasts to that endpoint as JSON with a bearer token, so an agent or a bridge outside the game can hear the room. The token comes from the DH_CHAT_FORWARD_TOKEN environment variable or the config/chat-forward-token file, never from a preference, so it cannot be committed by accident. Posting runs off the tick thread from a bounded queue; a hook that is down costs the game nothing and the lines it missed are dropped, not piled up. server.chat.forward.event names the event the receiver routes on.

server.chat.forward.kinds decides which lines are worth the trip, as a comma-separated list of chat kinds. It defaults to player,server — what somebody actually said — because a join and a leave are the server’s own bookkeeping, and on the far end of a hook that wakes an agent, each one spends a person’s attention on nothing. The list is read per line, so a console edit applies to the very next thing said, and player,server,join,leave puts every line back on the wire. Everything filtered out is still broadcast in the game and still printed in the server’s log; it simply is not posted.