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, all gated behind a config flag so that with the feature off the mod is a byte-for-byte no-op against stock netcode.


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.Active() (Enabled && SixPlayers config). With the flag off, 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 config-off client must be able to play with a vanilla client.

  3. The widened format is all-or-nothing. Once you're in six-player mode the wire format genuinely changes (the machine field is 3 bits, not 2). A widened host and a 2-bit peer cannot share a session. This is a deliberate, latched-from-startup contract, not a runtime handshake โ€” see Why config-latched, not handshake-latched.

  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 three static methods so the feature has exactly one source of truth:

internal static bool Active() =>
    Plugin.Enabled.Value && Plugin.SixPlayers.Value;

internal static int Cap() {
    if (!Active()) 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() => Active() ? 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 (Active()) __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.


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 why config-latched not handshake-latched

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.

Why not flip the wire format at runtime when the 5th player is invited? Because the serialiser and deserialiser must agree on the machine field width the instant the first roster message crosses โ€” and a runtime handshake races that first message. Latching the width from startup config (Active() is fixed at boot) means both ends always agree. The cost is the all-or-nothing contract: everyone in a six-player session must opt in before joining. A dynamic "start at 4, re-form for six when a 5th is invited" transition is possible but is a genuine re-lobby (re-establish the session among mod-compatible peers), not a live in-place bit-flip โ€” that race is exactly what we refuse to ship.


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.

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 remaining work is minor and cosmetic โ€” rendering players 5-6 on the 4-slot boards (needs scene-object cloning), the horn-cosmetic array, a native versus team-picker โ€” plus the optional dynamic "re-form for six" transition described above. 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.