Host, Join, and Solo in One Build
Status: Design agreed (2026-10); see DecisionsDecisions. Nothing here is implemented yet.
Goal
Any released game executable can be played alone, can host a game other players join, and can join a game someone else hosts — chosen at run time, from the game's own flow: a menu comes up first, in a world of its own, and choosing Host or Join turns that into a session. One build carries everything; nothing is a separate executable.
The first test is deliberately small: direct IP on a local network, two machines or two processes on one.
Not in this design: host migration and per-entity authority transfer (the rest of the ListenSim design in Overview, which this is the first stage of), NAT traversal, session discovery, and typing an address into the game (GG-UI text fields exist; the screen itself does not).
Where It Starts From
| Piece | Today |
|---|---|
| Role | Fixed when the engine is built (TNX_NET_MODEL): Server (Authority only, forced headless), Client (Owner only), or PIE (editor). A windowed game build is Client |
| Connecting | Outside the editor's play-in-editor and Testbed tests, nothing calls NetConnectionManager:: or Connect. --client <ip> and --port fill EngineConfig fields nothing reads |
| Playing alone | A Client build has no Authority and no Souls (Known Issue 6): it cannot spawn a player without a remote Authority |
| What the role is, in a world | A type: World<TNet, …> holds a LogicThread<TNet, …> whose net policy (SoloSim, AuthoritySim, OwnerSim) is a member. The policy is called at two points of a fixed step — input in, frame published — and at world setup (an Owner's input ring; an Authority's binding to replication). Nothing else in a world depends on it |
| A local player on the Authority | Already modelled: owner 0's Soul is given both Authority and Owner roles (FlowManagerBase::OnClientLoaded), and WorldBase::GetSimInput(0) is the window's input. It has only ever run inside the editor |
| Both sides in one process | PIENetThread drives one AuthorityNet and any number of OwnerNet handlers over one connection manager, routing each message by which side its connection is on. Editor builds only |
| Emptying a world | WorldBase:: wipes entities, handles, and caches in place. Tests use it; no game path does |
| Replacing a world | A game build creates one world at startup and keeps it. The renderer is given that world's registry and logic thread once. Only the editor shows other worlds, through its own viewports |
The Model
One world, whose role changes
The world a game build starts with is the world it keeps. What changes at run time is what that world is doing:
| Role | The world's net policy acts as | Net session | The local player |
|---|---|---|---|
| Solo | Authority | None | Owner 0 |
| Host | Authority | Listening | Owner 0; each Owner that joins gets 1–255 |
| Owner (joined) | Owner | Connected to a Host or a dedicated Authority | The OwnerID the Authority assigns |
Solo is a Host nobody can reach. The same Authority path, the same GameMode path, the same Soul for owner 0, and no listening socket. So going from the menu to hosting is not a change of world at all: the session starts listening and the level changes. A solo game can be opened to others the same way. It also makes Known Issue 6 go away rather than be worked around: there is one way a local player comes to exist.
Joining is the one change of kind, Authority to Owner, and it is a change of policy on the same world, after the world has been emptied.
What has to become adjustable, and what does not
| At run time | Why | |
|---|---|---|
| The world's role | Yes | A net policy for game builds (ListenSim, the name already reserved for it) that holds both the Authority's and the Owner's and uses the one the role says. The cost is one branch at each of the two points a step calls its policy. The typed policies stay as they are for the stripped builds and the editor |
| The net session | Yes | Listen, connect, close — owned by the flow manager, so a state that needs one gets one and a state that does not loses it |
| The world's contents | Yes | Unloading the level and emptying the world in place: entities, Constructs of World and Level lifetime, physics bodies, replication channels, input logs, rollback history, and the frame counter an Owner aligns to its Authority's. Built on ResetRegistry, which has to grow from a test helper into something a game can rely on |
| Rates (net update, input send) | Yes | Already read from the config where they are used |
| Capacities (entity and body counts, history length) | No | Slabs are sized and committed when the world is created (Known Issue 33). A menu and a session share the project's sizes. A game that wants a small menu world and a large session world needs a new world; that is the Reset World travel lever, and it is not needed here |
| Rollback, determinism, fixed step | No | Compile-time, as today |
Replacing the world — destroying it and creating another, with the renderer and the engine re-pointed — is what Game Flow calls Reset World. It remains worth building, for games whose worlds differ in size and as the strongest guarantee that nothing carries over. It is not on the path to hosting and joining, and this design does not depend on it.
Starting and ending a session
A game's GameplayState is the same class whether it is hosting, joined, or solo. So the role is given when a session is started, not baked into the state:
// From a menu state, in response to a button. flow.StartSession({ .State = "Gameplay", .Role = SessionRole::Solo }); flow.StartSession({ .State = "Gameplay", .Role = SessionRole::Host, .Port = 27015 }); flow.StartSession({ .State = "Gameplay", .Role = SessionRole::Owner, .Address = "192.168.1.20", .Port = 27015 }); // From anywhere: leave, and go back to a state that says what it needs. flow.EndSession("MainMenu");
StartSession is a transition: the current state exits and its level is unloaded; the world is emptied; its role is set; the net session is brought up (listen or connect); the state is entered. EndSession closes the net session, empties the world, sets it back to Solo, and enters the named state. A session that fails — the port is taken, nobody answers — ends back in the state it was started from with a reason the state can show.
The menu is an ordinary state with a Solo world: a level behind it if it wants one, and a GG-UI document in front.
What changes underneath
| Piece | Change |
|---|---|
| Build model | A new TNX_NET_MODEL=Game: both net handlers compiled, the Authority side not tied to headless, ListenSim as the world's policy. It becomes the model of windowed, non-editor builds. Server (headless, Authority only) and Client (Owner only) remain for stripped binaries; PIE is unchanged |
| Net policy | ListenSim: both policies and a role. Owner setup (the input ring) is done once for a world that may become one |
| Net thread | One net thread for game builds that owns the connection manager and uses AuthorityNet or OwnerNet as the role says. It is PIENetThread made general |
| Net session | A real thing the flow manager owns. NeedsNetSession is enforced like NeedsWorld |
| Emptying a world | A supported operation, run between steps with the logic thread held, that leaves a world as it was when created |
Role-specific code behind #if | The few places that compile one side out (GameplayState's local-player spawn, FlowManager::'s level-ready acknowledgement) ask the world's role instead |
| Command line | --host, --join <address>, --port start a session directly, without the menu: for tests and for scripts |
Build Order
| Step | Work | Test |
|---|---|---|
| 1 | The Game build model, ListenSim, and the game net thread. The role comes from the command line and is fixed for the run | A windowed Host with a local player; a second process joins it over loopback and both see both players; Solo spawns its player with no net session |
| 2 | Emptying a world in place; changing its role between sessions | Solo → empty → Host → empty → Owner → empty, many times over, each session behaving as a freshly started process does; no growth in memory, handles, or bodies |
| 3 | StartSession / EndSession; the net session owned by the flow manager; failure returns to the calling state | Hosting on a taken port and joining an address nobody answers both end in the menu with the reason |
| 4 | The resilience demo: a menu state and a GG-UI menu (Solo, Host, Join the configured address), and leaving a session back to the menu | Two machines on a local network |
Known Limits Carried In
These exist today and a Host inherits them; none is made worse:
- One Owner far behind stalls the Authority's sim for everyone (Known Issue 17).
- With rollback compiled out there is no reconciliation of the controlled entity (Known Issue 16). The demo build has rollback on.
- The Host's own player is not predicted: it is the Authority. It has no latency to hide.
- If the Host leaves, the session ends for everyone. Migration is the later
ListenSimwork. - No interest management: every Owner is sent every entity.
Decisions
Decided 2026-10: each as proposed.
| # | Question | Decision |
|---|---|---|
| 1 | Solo is a Host that is not listening: the Authority path for both | Yes |
| 2 | One world whose role is set at run time, through a ListenSim policy for game builds, rather than a world replaced by one of another type | Yes: it removes the largest unknown (no game build has ever replaced its world) and makes menu → Host no change of world at all |
| 3 | Joining and leaving empty the world in place instead of destroying it | Yes, with the test in step 2 as the proof that nothing carries over. Replacing the world stays available later as Reset World |
| 4 | The role is given when a session starts (StartSession({ State, Role, Address, Port }), EndSession(state)), not declared by the state class or fixed by the build | Yes |
| 5 | A third build model, Game, for windowed non-editor builds, with Server and Client kept as stripped builds | Yes |
| 6 | One general net thread for game builds, which the editor's play-in-editor thread later becomes a use of | Yes, with the editor moved onto it after game builds work, not at the same time |
Open Questions
- Everything a world holds that emptying must reach. The list above is from reading; step 2 starts by finding the rest (anything keyed by frame number, the streaming manager's requests in flight, audio voices).
- Whether Souls survive from the menu into a session. Game Flow says a Soul is session-scoped; the menu's owner 0 and the session's are then different Souls, and anything to carry across belongs in a Persistent Construct.
- How an Owner learns why it could not join (full, wrong version) beyond "nobody answered".