Provisions & Co.

How six-player online co-op works in OC2 Cartographer

Or: how we pushed Overcooked! 2 past its hardcoded four-player ceiling โ€” from the outside, with no source, no Unity project, and a shipping multiplayer wire format we were not allowed to break.

This is a technical postmortem for other modders and netcode people. It assumes you've worked with a Unity/Mono game before and are comfortable with IL, Harmony transpilers, and bitstream serialisation. Everything here is implemented in SixPlayerMode.cs plus a handful of hooks in NetSync.cs and PhantomClients.cs. Every session starts as a byte-for-byte stock four-player session and only switches to the widened format at the lobby, all peers together, when the host invites a fifth player.


TL;DR

Overcooked! 2 is built around exactly four players. That "4" is not one constant you can bump โ€” it is at least five architecturally distinct hardcodings, each of which fails in a different way and each of which needs a different technique to defeat:

# Shape of the stock "4" Failure at 5+ Technique
1 OnlineMultiplayerConfig.MaxPlayers property (4u) Admission/invite/lobby all capped Harmony getter postfix โ†’ Cap()
2 Bare IL literal 4 in count-gate branches 5th user rejected before app layer Transpiler rewrites ldc.i4.4 โ†’ call Cap() (surgically)
3 2-bit machine field in the roster wire format 5th PC's id truncates โ†’ duplicate host โ†’ everyone kicked Transpiler widens the field to 3 bits + a new id allocator
4 Steam lobby member-limit arg (inlining-proof) Transport rejects the 5th peer Transpiler retargets the CreateLobby arg directly
5 Scene ships exactly 4 chef GameObjects Players 5-6 have no avatar / get culled Deterministic chef-body cloning in the load pipeline

On top of those there is a long tail of fixed [4] arrays and lookups sized for 1โ€“4 players that only detonate once six chefs are actually playing (physics contact tracking, avatar-directory indexing, per-player-count scene variants, the pause/lobby UI). Each is its own bounds bug.

The single hardest lesson: the reason "the fifth player kicks everyone" is not an admission-gate problem at all โ€” it's a 2-bit wire field. We chased app-layer join gates for days before instrumenting the roster teardown and finding a machine id truncating on the bus.


Ground rules that shaped every decision

  1. No game source, no Unity project. We work purely against the game's public type and API surface, patching the assembly at runtime with HarmonyX. Anything that requires authoring a Unity scene (new UI slots, a new render-to-texture camera rig) is off the table โ€” we can only clone and re-wire objects that already exist in a scene at runtime.

  2. We cannot break the wire format for normal play. Cartographer is a co-op mod people run in mixed groups. Every six-player change is gated behind SixPlayerMode.InSixMode() โ€” the session's runtime wire width, which starts at the stock 2 bits on every launch. At width 2 every transpiler emits the original constant, every prefix returns true (run original), and the serialised bytes are identical to vanilla. This is non-negotiable: a mod user must be able to play with โ€” and join โ€” vanilla players.

  3. The widened format is all-or-nothing per session. In six-player mode the wire format genuinely changes (the machine field is 3 bits, not 2), so a widened peer and a 2-bit peer cannot share a session. The whole session therefore switches together, at the lobby only, through an atomic barrier โ€” see The handshake and the runtime 4 โ†” 6 switch.

  4. Determinism is load-bearing. OC2 multiplayer has no entity-id negotiation: every peer builds the scene hierarchy in the same order and derives the same EntitySerialisationRegistry ids positionally. Anything we add (extra chef bodies) must be added at the same pipeline point on every peer, or the ids skew and entity sync corrupts silently.


The single abstraction: Cap()

Everything routes through a few static methods so the feature has exactly one source of truth. Two ideas are kept apart: capability (this machine may do six-player โ€” admin-pinnable config) and the session's active width (what the wire format is right now, owned by the host):

internal static bool Capable() =>                 // allowed to take part at all (config)
    Plugin.Enabled.Value && Plugin.SixPlayers.Value;

internal static bool InSixMode() => ActiveWidth == 3;   // runtime: 2 on every launch, 3 after a grow

internal static int Cap() {
    if (!InSixMode()) return 4;                    // vanilla
    int want = clamp(Plugin.MaxOnlinePlayers, 4, 7);
    int peerMin = NetSync.MinConnectedClientCap(); // weakest connected peer
    return Math.Min(want, peerMin);                // never outgrow a cap-4 peer
}

internal static int MachineBits() => InSixMode() ? 3 : 2;

Cap() is clamped down to MinConnectedClientCap() โ€” the smallest cap any connected peer announced over our own handshake channel. This is the graceful-degradation valve: if one player forgot to enable six-player (or runs an old build that announces cap 4), the whole session stays at 4 instead of trying to grow past that peer's ceiling and collapsing. ConfiguredMax() returns the unclamped want so the host UI can say "you want 6 but Bob is forcing 4."

The wire ceiling is 7, not arbitrary: UsersChangedMessage packs the user count in 3 bits, so 7 is the hard max the roster message can express. We target 6, allow up to 7.


Shape 1 โ€” the MaxPlayers property

OnlineMultiplayerConfig.MaxPlayers (returns 4u) is the value most of the online stack reads: the transport join gate, the invite gate, the lobby theme arrays. A Harmony getter postfix rewrites the result:

[HarmonyPatch(typeof(OnlineMultiplayerConfig), "MaxPlayers", MethodType.Getter)]
static void Postfix(ref uint __result) { if (InSixMode()) __result = (uint)Cap(); }

Known risk (why we don't trust this alone): MaxPlayers is a trivial property Mono may inline into its callers, bypassing the postfix entirely. We log the observed value at a real call site and the actual Steam lobby member-limit so a bad inline shows up in the log โ€” and where inlining would be catastrophic (the lobby limit, shape 4) we don't depend on the getter at all.


Shape 2 โ€” bare literal 4 count-gates (the surgical transpiler)

Some admission gates don't read MaxPlayers; they compare a user count against a hardcoded 4: ServerUserSystem.AddUser / AddNewRemoteUser, and โ€” the one an earlier exploration missed โ€” the real app-layer remote-join gate, Server.OnlineMultiplayerSessionJoinDecisionCallback (two sites).

A transpiler rewrites ldc.i4.4 โ†’ call Cap(). The interesting part is precision. A blanket "replace every load-of-4" is wrong: these methods contain unrelated 4s, most dangerously the enum compare JoinMethod == NetConnectionState.Matchmake where Matchmake == 4. Rewriting that to == Cap() (== 6) silently corrupts join routing.

So the transpiler only rewrites a 4 that is both:

if (IsLoadFour(code[i]) && IsComparisonOrBranch(code[i+1].opcode)
                        && PrecededByCountAccess(code, i)) {
    code[i].opcode  = OpCodes.Call;
    code[i].operand = s_capMethod;   // call Cap()
}

Instructions are mutated in place (not replaced) so Harmony preserves labels and exception-block boundaries. The transpiler logs how many sites it rewrote per method (2 in the join callback, 1 each in the AddUser methods) so a future game update that introduces a stray 4 is caught in review rather than in production.


Shape 3 โ€” the 2-bit machine field (the real "everyone gets kicked" root cause)

This is the one that mattered. For a long time the symptom was: the session is fine at 4, and the instant a 5th player joins, every player including the host is removed (GoOffline). We assumed an admission cap and kept hardening app-layer gates. It wasn't an admission problem.

OC2 identifies each physical machine by User.MachineID { One, Two, Three, Four, Count = 4 } and serialises it into a 2-bit field in UserData:

// UserData.Serialise / Deserialise
writer.Write((uint)machine, 2);      // 2 bits โ†’ ids 0..3
machine = (MachineID)reader.ReadUInt32(2);

ServerUserSystem.GetAvailableMachineId() returns Count (= 4) once the four real slots fill. So the 5th PC is handed machine id 4, and 4 & 0b11 == 0 โ€” it truncates to One on the wire, a duplicate of the host. The duplicate machine corrupts the roster; ClientUserSystem.OnUsersChanged ends up seeing no valid local user, DisconnectionHandler.OnClientUsersChanged fires HandleKickMessage โ†’ GoOffline, and everyone is torn down.

We only found this by patching a roster dump right before the kick decision (DumpUsersBeforeKickCheck, manually wired in NetSync.RegisterDynamic because the type is non-public). The dump showed the remote 5th player as machine=One IsLocal=True on the host โ€” the smoking gun of a truncated id.

The fix is two coordinated patches:

(a) Widen the field to 3 bits. A transpiler on UserData.Serialise/Deserialise replaces only the machine field's width literal (the 2 feeding Write(uint,2) / ReadUInt32(2)) with call MachineBits(). UserData packs several other 2-bit fields (team, padSide, splitStatus, partyPersist), so a blanket ldc.i4.2 replace would corrupt those. We anchor on the field access โ€” ldfld machine on the write side, stfld machine on the read side โ€” and rewrite the nearest load-of-2 on the correct side of the anchor, so exactly one site changes:

// find `ldfld/stfld UserData.machine`, then the adjacent ldc.i4.2, โ†’ call MachineBits()

(b) Allocate real ids past 4, skipping the sentinel. A prefix on GetAvailableMachineId hands out ids from { 0, 1, 2, 3, 5, 6, 7 } โ€” skipping 4 so Count keeps meaning "no free slot." This matters because the game is littered with == Count invalid-checks; we verified there are no >= Count guards, so skipping the value 4 (rather than extending the enum contiguously) keeps every existing equality check correct.

Both patches are inert when the flag is off (MachineBits() returns 2; the allocator prefix returns true to run the stock method), so the UserData wire layout is byte-identical to vanilla.

The other machine-bearing wire fields (GameStateMessage, ControllerSettings, ChefAvatar, the join-reply in Server) were already 3-bit in stock โ€” UserData was the lone 2-bit outlier. That's why the bug hid: 90% of the format already tolerated ids up to 7.


Shape 4 โ€” the Steam lobby member limit (inlining-proof)

The host creates its Steam lobby with member limit (int)MaxPlayers. If Mono inlined the MaxPlayers getter here, the shape-1 postfix never runs and the lobby silently caps at 4 โ€” a hard block below the transport that no app-layer patch can undo (the 5th peer can't even reach us).

So we don't trust the getter here. A transpiler on SteamOnlineMultiplayerSessionCoordinator.UpdateCreatingLobby finds the CreateLobby call, walks back past conv/nop to whatever instruction produces the int limit argument โ€” whether that's call get_MaxPlayers or an inlined ldc.i4.4 โ€” and rewrites it to call Cap(). MaxPlayers appears exactly once in that method, so the target is unambiguous.

Other mods own these numbers too. OC2HostUtilities (from 1.8, its spectator feature) transpiles the same two coordinator methods โ€” UpdateCreatingLobby and the host's join check ReceiveJoinRequestMessage โ€” swapping the MaxPlayers call for its own GetTransportMemberLimit() (4, or 4 + its spectator setting), and re-sets the Steam lobby limit every frame. A swapped call never reaches a getter postfix, so with it installed the host answered the 5th player's join request eFull ("unable to join the game session because it is full") although our lobby was six-wide. We now (a) postfix its two limit functions to at least Cap() while six-wide, keeping its larger value when spectators are on, and (b) guard SteamMatchmaking.SetLobbyMemberLimit itself: while six-wide, any request below Cap() โ€” from whichever mod โ€” is raised back and the first caller is logged. At vanilla width neither acts.


Shape 5 โ€” materialising chef bodies for players 5-6

Every kitchen scene ships exactly four chef GameObjects (each with a PlayerIDProvider giving it Player.One..Four). Two stock steps conspire against extra players:

So a 5th/6th user gets no body โ€” or worse, gets culled. We have to add chef bodies before both steps, and we have to do it deterministically across peers because of the positional entity-id derivation.

The clone runs inside DataLevelLoader's apply pipeline โ€” the same network-synced JSON, on every peer, during LoadKitchen, before ScanNetworkEntities walks the scene to assign entity ids. We sort chefs by id, take the lowest (Player.One) as the template, Object.Instantiate it under the same parent, and reassign the clone's PlayerIDProvider to the next free id via OverridePlayerId(Player.Five/Six):

GameObject clone = Object.Instantiate(template.gameObject, parent);
clone.GetComponent<PlayerIDProvider>().OverridePlayerId((Player)maxId);

Cloning identically at the same pipeline point on every peer yields the same entity ids by the same determinism the mod already relies on for placed objects.

The count has to be synced, not locally derived. A client may still be receiving its user roster when it starts building the scene (phantoms, or a real 6th joiner arriving late). If each peer cloned localUserCount bodies they'd disagree. So the host computes ComputeHostTargetCount() (real users + host phantoms, capped) and broadcasts it over our own NetSync channel (reserved scene cartographer-6p, count in DataLevelMessage.total) before the level-load message. Every peer clones s_syncedTargetCount bodies regardless of roster-sync timing.

We proved this across two independent processes: two live Overcooked2 instances, both dumped their post-cull chef tables (ClientChefTableDiagnostic), and the entity id sets matched exactly โ€” {8,9,10,11,12,13} including the cloned Player.Five=12 / Six=13 on both the host and the client. Cross-process entity-id determinism holds.


The long tail โ€” fixed [4] arrays and 1-to-4 lookups

Admission and bodies get you into the level. Then you meet everything that was sized for four players and only crashes once six are playing. Each was found empirically (instrument โ†’ reproduce โ†’ fix), because you cannot reason these out from the type surface alone:

The physics-contact clamp is representative of a class โ€” "fixed-N array indexed by player count" โ€” and the general lesson is to sweep for its siblings rather than patch one symptom.


The handshake and the runtime 4 โ†” 6 switch

Because the widened format is incompatible with a 2-bit peer, we need peers to agree before they collide, and we need the host to explain mismatches instead of silently degrading.

We piggyback on the mod's existing presence handshake (a reserved HelloScene on our own reliable type-71 NetSync channel). Each peer's Hello now carries its Cap(); the host records per-connection caps in s_clientCaps. MinConnectedClientCap() feeds Cap() (graceful degrade), and RosterTick warns the host โ€” on-screen and in the log, throttled โ€” when a connected peer is forcing the cap below what the host configured, naming who to fix.

Flipping the width at runtime. The serialiser and deserialiser must agree on the machine field width the instant a roster message crosses, so no peer ever changes it on its own. The first releases latched it from startup config, which meant a six-player-enabled user could not join vanilla lobbies at all. The current design flips it with an atomic barrier, only at the lobby (never inside a kitchen): the host broadcasts prepare(epoch, width), every live remote peer acks, and the host broadcasts commit once all have acked; a peer joining or leaving mid-flip, or a 12-second timeout, aborts it and nothing changes. The decision (BarrierLogic.Decide) is pure, Unity-free code shared with a headless test harness that drives it for 5, 20 and 100 peers.


How we tested a 6-machine feature without 6 machines

Multiplayer bugs you can't reproduce are bugs you can't fix. Two pieces of test infrastructure made this feature tractable:

Phantom clients (PhantomClients.cs). Injects fake remote users into ServerUserSystem and mirrors host GameState onto them each frame so level-load state gates pass. This is safe in-process because the host broadcasts over its connection list, not its user list โ€” a connectionless user is never messaged or awaited. Phantoms test admission and that the session survives six users host-side. What they cannot test: real cross-process netcode, or entity-id alignment across independent EntitySerialisationRegistry instances (phantoms share the host's single registry).

A real multi-process socket rig (NetworkSim.cs). For the things phantoms can't reach, we built a localhost-TCP SocketConnection : BaseConnection (mirroring the game's own LocalLoopbackConnection), Harmony-patched the network configurator to use it in test mode, and stood up two actual Overcooked2 processes on one PC (steam_appid.txt lets a directly-launched process SteamAPI_Init without the Steam relauncher). We reproduced the real join, byte-for-byte: the host builds the authentic join reply (machine(3b) + TimeSync + UsersChanged + GameSetup) out-of-band, the client decodes it exactly as JoinSessionBaseTask does and applies it to a real Client peer. The capstone: two independent processes holding a synced 2-machine roster over a socket, no Steam โ€” which is where the cross-process entity-id determinism proof came from.

Lobby-then-load with six real processes (LobbySim.cs + tools/localsteam). The socket rig seats peers into an already-running level, which is not how a group plays. The lobby-sim replaces Steam only at the very bottom: a dev-only DLL stands in for SteamMatchmaking (lobby create/join/leave/members/data/limit) and SteamNetworking (P2P send/read) through a localhost hub run by the host process. Everything above it is the real game and mod โ€” the session join handshake and keepalives, the invite, the F8 grow, the width barrier, the map pick from the browser, the load, chef assignment. Six Overcooked2 processes play the normal flow end to end (lobby-sim.ps1 -Players 6); -Keepalive N and -HostStallSec N reproduce a slow host on a heavy map. It found three bugs no single-process test could: the grow refused when F8 came before the 4th player's mod hello, and the two HostUtilities limits above.

This rig is also the foundation for later TAS/bot work: each sim player is a full Unity process with its own statics (its own machine identity), driven by the same ChefDriver actuator the automated test harness uses.


Status and what's left

Five-player online co-op is validated on real hardware (v0.6.83): the 4-machine wire limit is broken, admission/chef-cloning/physics-clamp all shipped, and a full round plays to the results screen at six. The runtime 4 โ†” 6 switch shipped in v0.6.163 (its shrink rule was corrected in v0.6.224), and the lobby shows six slots in six-player mode. A six-player round on a heavy map once dropped all five clients at once mid-load: the game's session keepalive gives up on a peer after 5 seconds without a packet, and a slower host froze its main thread longer than that building the kitchen. The lobby-sim reproduced it exactly (an 8-second host stall drops everyone at the 5-second timeout; at the mod's 120 seconds all six reach the kitchen), so the mod now raises the timeout for every player and logs the longest frame of each load. The remaining work is minor and cosmetic โ€” the horn-cosmetic array and a native versus team-picker. None are blockers.

The through-line, if you take one thing from this: a hardcoded player count is never one number. It is a property, a pile of IL literals, a wire-field width, an inlined argument, a set of scene objects, and a long tail of fixed arrays โ€” and each of those is a different kind of fix. Find all the shapes before you start, and instrument the failure before you theorise about it. The bug that cost us the most days โ€” the 2-bit machine field โ€” looked exactly like an admission-gate problem and was nothing of the kind.