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 plus a handful of hooks in
SixPlayerMode.cs and NetSync.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.PhantomClients.cs
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
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.
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 && SixPlayersconfig). With the flag off, every transpiler emits the original constant, every prefix returnstrue(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.The widened format is all-or-nothing. Once you're in six-player mode the wire format genuinely changes (the
machinefield 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.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
EntitySerialisationRegistryids 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:
- immediately consumed by a comparison/branch opcode (
ceq/cgt/beq/bge/โฆ โ it's one side of a numeric gate), and - preceded (within ~8 instructions) by a
get_Countcall โ i.e. the compared value came from a user-count read, not an enum getter.
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 inServer) were already 3-bit in stock โUserDatawas 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:
AssignChefEntitiesmaps users onto the sorted provider list and stops at the provider count.ClientKitchenLoader.AssignChefOwnershipFromNetworkthen destroys any chef no user owns.
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:
GetSceneVarient(5/6)returns null โ load hangs. Scenes define per-player-count variants only for 1โ4 (or a-1wildcard). At 6 usersServerMessenger.LoadLevelcan't match a variant by index, falls back to load-by-name, which never populatesGameSession.LevelSettings.SceneDirectoryVarientEntryโGetLevelConfig()is null โ the round timer and flow controller NRE โ infinite load. This breaks real six-player at first load, not just an edge case. Fix: a prefix clamps the variant lookup to 4 when active (if (_playerCount > 4) _playerCount = 4). Chef count is synced independently, so the 4-player variant just supplies the base kitchen + config while all six still seat.ServerPhysicsObjectSynchronisertracks colliding chefs in[4]arrays (kTrackedChefCount = 4). In six-player chaos, 5-6 chefs crowd one pot/plate/crate,StoreContactInformationwritesContacts[4]/[5]โIndexOutOfRangeon the host โ session drops. (ContactCountis a 3-bit wire field so it would even transmit the overflow, corrupting clients too.) Fix: a prefix trims the colliding-chefs list to 4 before the write. Cost is that a 5th chef simultaneously contacting one object isn't network-predicted that frame โ self-corrects via normal position sync, proportionate against a hard crash.User.RefreshSelectedChefDataindexes the avatar directory with no bounds check.Colours[Colour]whereColouris the user index (0..5) and the colour list can hold fewer than six โ throw mid chef-ownership setup โAssignedChefsToUsersnever sent โ hang. Fix: a prefix clamps colour/avatar indices modulo the real array lengths (cosmetic โ players 5-6 may reuse a look).KitchenBootstrapManager.GetDefaultChefonly defines four costumes. Postfix returnsindex % 4for players 5+ so they get a valid avatar instead of null.UI boards ship exactly four slots and render busts through an ortho camera framed for four. The pause/customise menu (
UIPlayerRootMenu) and the lobby chalkboard (FrontendPlayerLobby) can't be widened without authoring new scene objects (which we can't). So we clamp, not widen: a transpiler caps the pause-menu creation roster to 4; aFocusMenuprefix intercepts them_PlayerSlots[users.Count]index whencount > slots.Count, replays the two mandatory side-effects, and parks the selection on the last real slot. Players 5-6 simply don't appear as cards โ purely cosmetic, no gameplay impact. A proper fix (cloning theFrontendPlayerSlotobjects) is deferred because it needs real scene inspection, not blind code.
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.