Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

CrewSwap is a Barotrauma mod that brings singleplayer-style crew switching to the multiplayer campaign. Open the crew list, click a bot’s row, and you are now playing that crew member. The character you left goes back to being a bot and carries on with whatever it was doing. Over a campaign, every player can drop in and out of any free crew member, level several characters, and never lose or duplicate one of them along the way.

Vanilla multiplayer gives each player exactly one character. Outside of the take-over prompt after a permanent death, the only built-in way to control a bot is a cheat command, and that one corrupts the campaign at the next save. CrewSwap does it properly: the server decides every switch, and before the game saves, everything goes back into the shape vanilla expects.

Beta: version 0.9.0

All of CrewSwap’s server-side logic is covered by an automated unit-test suite and has passed an 18-scenario automated test run on a real dedicated server. What has not been fully tested yet is live switching with two or more real players on real clients. Expect rough edges. Before you trust it with a campaign you care about, read Troubleshooting so you know how to load a backup. Please report anything odd on the issue tracker.

Features

  • Click to switch. Click a free crew member’s row in the crew list to take control of them. Rows that someone else is playing are greyed out and labelled with that player’s name. Dragging rows and the order buttons work as usual.
  • Shared pool or own crew. In shared mode (the default), anyone can take any free crew member. In own mode you can take your own character, crew you own (hired in own mode, claimed by playing them first, or assigned by an admin) and crew nobody has claimed yet (taking one claims it).
  • Home protection (on by default). Your own character can only be played by you. Turn it off and your character joins the pool while you are playing someone else.
  • Jump in on death. When the character you are playing dies, pick another free crew member straight away instead of waiting for the respawn shuttle.
  • Per-character wallets. Money belongs to the character, not to the player, and switching never moves it. Bots keep their wallets through saves.
  • Crew come back. Outside Permadeath, a crew member who dies is no longer deleted for good the way vanilla deletes dead bots. They return as a bot at the start of the next round (after a saved round, with vanilla’s usual death penalty to their skills).
  • Fair round-end pay. Mission pay and round-end rewards go to the character each player is playing when the round ends (on save-and-quit it goes to each player’s own character instead).
  • Safety first. CrewSwap checks the crew before every save and refuses to write a save that would lose or duplicate a character. It keeps 20 rolling backups per campaign, and it switches itself off if the game version is not one it was built for (a different LuaCs build gets a warning, and every patch must still install cleanly or CrewSwap switches itself off).
  • Admin chat commands. !crewswap status, pool, protect, owner and check, typed in game chat. See Admin commands.
  • Stays out of the way. CrewSwap only runs in multiplayer campaigns. It stays off in sandbox, mission and PvP rounds and in Ironman campaigns, and with nobody switching, a campaign plays just like vanilla.

CrewSwap is written in pure Lua on top of Lua for Barotrauma (LuaCs) and ships no C# code, so players never see a “run C# code” prompt because of it. The client half runs sandboxed. The server half needs LuaCs’s server Lua left unsandboxed (the default C# run policy).

Where to start

You are…Read
A player joining a CrewSwap serverRequirements, Client install, Playing
Running a dedicated serverRequirements, Server install, Admin commands
Curious about what changesDifferences from vanilla
Something went wrongTroubleshooting, FAQ
A developerArchitecture overview, Contributing

A note on how this mod was made

CrewSwap was built with AI assistance (Anthropic’s Claude), directed and reviewed by a real software engineer. It started as a personal project for playing Barotrauma with friends, and it is shared here in case other crews enjoy it too.

If you’d rather not use AI-assisted mods, no hard feelings. You don’t have to use it.

Licence

CrewSwap is free software, licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only). The source code is on GitHub. If you run a modified version on a server that other people play on, the AGPL asks you to offer them your modified source.

Barotrauma is a trademark of its respective owners. CrewSwap is a fan-made mod and is not affiliated with or endorsed by the game’s developers or publishers.

Requirements

Versions

ComponentVersion CrewSwap 0.9.0 was built for
Barotrauma1.13.4.x (any 1.13.4 revision)
Lua for Barotrauma (LuaCs)tested against the build at commit 85ded59; other builds get a warning (Workshop item 2559634234)

CrewSwap reaches deep into the campaign’s save code, so it is strict about the game version. When the server starts, CrewSwap reads the real game version and the LuaCs build.

  • Game version. If it doesn’t match, CrewSwap turns itself off completely. It installs nothing and tells players in chat, and the campaign carries on as plain vanilla. See Version mismatch.
  • LuaCs build. LuaCs updates itself through the Workshop, so a different build doesn’t turn CrewSwap off. It logs a warning in the server console and keeps going. Its other checks still guard the campaign: if any patch fails to install, or one of the start-up or runtime checks fails (the live crew-list check on the first campaign round, the AI-body audit, the required game API checks), CrewSwap switches itself off (the optional in-game selftest only reports problems in the console and doesn’t switch it off). See Different LuaCs build.

When Barotrauma updates, CrewSwap stays off until a matching CrewSwap release comes out. This is on purpose: a mod that patches save code should never guess. If something misbehaves after a LuaCs update, put back the tested LuaCs build if you can and report it.

Game modes

ModeCrewSwap
Multiplayer campaign: respawn modes MidRound, BetweenRounds, NoneActive
Multiplayer campaign: Permadeath (Ironman off)Active
Multiplayer campaign: IronmanOff (vanilla Ironman rules apply)
Sandbox, mission and PvP roundsOff
SingleplayerNot applicable (the game already lets you switch)

MidRound and Permadeath get the most testing. BetweenRounds and None are supported, but they get lighter testing.

Who needs what

LuaCsCrewSwap
Dedicated serverServer-side LuaCs installedIn the server’s LocalMods folder and enabled (Server install)
Every player who wants to switchClient-side LuaCs installed (Client install)Downloaded automatically from the server on joining (subscribing on the Workshop is optional)
Players without client-side LuaCsnoneCan still join and play normally; they just can’t switch

Some more detail:

  • The server must run unsandboxed Lua. That is the LuaCs default. If an admin has turned off C# for the server in LuaCs’s settings (run policy Disabled), server Lua is sandboxed. CrewSwap then logs server Lua is sandboxed and stays off.
  • Clients run sandboxed Lua. CrewSwap’s client half is pure sandboxed Lua and ships no C#. Players never need to accept a “run C# code” prompt for CrewSwap.
  • Players without client-side LuaCs are never kicked. Once per connection, about 90 seconds after they first enter a round while CrewSwap is active, they get one chat line explaining how to turn on client-side LuaCs. They get no notice in a non-campaign round, under Ironman or while CrewSwap is off.
  • Mod downloads must be allowed on the server (the vanilla server setting, on by default), so joining players receive CrewSwap automatically.

Platforms

  • Server: any platform the Barotrauma dedicated server and LuaCs support (Windows or Linux). CrewSwap itself is plain Lua and has no platform-specific parts.
  • Clients: Windows and Linux. The current LuaCs client installer (a Steam launch option) does not support macOS. See Client install.

Server install

This page covers a dedicated server, which is what CrewSwap is built and tested on. See Hosting from the game client at the end for the in-game “Host server” button.

Before you start, back up your server’s multiplayer saves (the Multiplayer folder in the server’s Barotrauma data folder). CrewSwap is beta software.

1. Install LuaCs on the server

CrewSwap needs the server-side part of Lua for Barotrauma (LuaCs). Follow the dedicated-server instructions on the LuaCs Workshop page or in the LuaCs GitHub repository. Many hosting panels also have a LuaCs option for Barotrauma servers.

Check two things:

  • Ideally, use the LuaCs build CrewSwap was tested against (see Requirements). A different build only logs a warning at start-up, and CrewSwap keeps running as long as every patch installs. Step 4 below shows how to check this from the console.
  • Leave LuaCs’s C# run policy at its default. CrewSwap’s server half needs unsandboxed server Lua. If C# is disabled for the server, CrewSwap stays off.

2. Put CrewSwap in LocalMods

Download the CrewSwap release and copy the CrewSwap folder into your server’s LocalMods folder, which sits next to the server executable. The result should look like this:

<server folder>/
  LocalMods/
    CrewSwap/
      filelist.xml
      ModConfig.xml
      Content/crewswap_sync.xml
      Lua/crewswap/...

A few rules:

  • The folder must be named CrewSwap, and filelist.xml must be directly inside it.
  • Copy only the mod folder. Don’t put the repository’s tests/ or docs/ folders on the server. The server sends everything in the mod folder to every joining player.
  • Never put your own files in LocalMods/CrewSwap. CrewSwap keeps its settings and runtime files in the server’s save folder instead (see Admin commands).

Content/crewswap_sync.xml is an empty item file. It is there on purpose: it makes CrewSwap a synced content package, so the server requires it and sends it to players who don’t have it.

3. Enable it

A dedicated server reads its enabled content packages from config_player.xml in the server’s working folder, not from serversettings.xml. Stop the server first, then add CrewSwap under <regularpackages>:

<contentpackages>
  <corepackage path="Content/ContentPackages/Vanilla.xml" />
  <regularpackages>
    <!-- LuaCs, if your install lists it here (LuaCs adds itself when it is missing) -->
    <package path="LocalMods/CrewSwap/filelist.xml" />
    <!-- your other mods -->
  </regularpackages>
</contentpackages>

Keep the server setting that allows mod downloads switched on (it is on by default), so players receive CrewSwap when they join.

If your hosting panel manages config_player.xml for you, enable the mod in the panel instead, and check after the next restart that the line is still there.

4. Start the server and check the console

Start the server and look for these lines in the server console or log:

[CrewSwap] server loaded build <build>
[CrewSwap] running on game 1.13.4.<n>, LuaCs <revision>
[CrewSwap] save folder: <path>
[CrewSwap] admin: config: pool=shared protect=on (defaults)
[CrewSwap] modules loaded: game, patch, selftest, spawn, identity, switch, presence, roundend, death, crewdown, safety, admin
[CrewSwap] patches installed: N/N
[CrewSwap] active: false
  • LuaCs <revision> is the LuaCs commit as LuaCs reports it, which may be a full hash; with the tested build it starts with 85ded59.
  • patches installed: N/N, with both numbers the same, means every patch went in.
  • If the LuaCs build isn’t the tested one, a [CrewSwap] WARNING: built for LuaCs ... line follows the running on game line. CrewSwap keeps running; see the warning signs below. If LuaCs’s build can’t be read, the revision is blank and the warning says running on unknown.
  • active: false is normal at this point, because no campaign is loaded yet.
  • Note the save folder path. CrewSwap’s config file and in-game self-test switch live in a CrewSwap folder inside it.

Then load or start a multiplayer campaign and start a round. You should see:

[CrewSwap] round start, active: true

Warning signs:

Console lineMeaning
[CrewSwap] OFF: version mismatch (...) and no patches installed lineWrong game version (or the version couldn’t be read): CrewSwap is off. See Troubleshooting.
[CrewSwap] OFF: version mismatch (server Lua is sandboxed)C# is disabled for the server in LuaCs.
[CrewSwap] WARNING: built for LuaCs <expected>, running on <found>; ...A different LuaCs build than the one tested. CrewSwap keeps running; check that patches installed shows both numbers the same. See Troubleshooting.
[CrewSwap] FAIL: <reason>CrewSwap switched itself off. See Troubleshooting.
No [CrewSwap] lines at allThe mod isn’t enabled, or LuaCs isn’t running on the server.

5. Tell your players

Every player who wants to switch needs client-side LuaCs. Point them to Client install. Players without it can still join and play, but they can’t switch.

Updating CrewSwap

  1. Stop the server.
  2. Replace LocalMods/CrewSwap with the new version. Your settings live in the save folder, so you lose nothing.
  3. Start the server and check the console as in step 4.

The server packs the mod for download once, at start-up, so a new version only reaches players after a restart. Players get it automatically when they next join. Anyone who is still running an old copy is told in chat to rejoin.

Removing CrewSwap

End the session on a saved round end (a level transition or save-and-quit). Before every save, CrewSwap puts every player back in their own character, so the save is in vanilla shape. Then stop the server and remove the config_player.xml line. The campaign keeps loading in vanilla, which ignores CrewSwap’s extra save data. From then on vanilla rules apply again: dead bots are deleted, and bots’ money isn’t kept. See the FAQ.

Hosting from the game client

CrewSwap is built and tested for dedicated servers. The server half needs LuaCs running server-side with unsandboxed Lua. Whether that works on a server started with the in-game “Host server” button depends on how LuaCs is installed on the host’s machine, and it hasn’t been tested. If you try it, enable CrewSwap in the host’s mod list as you would any other mod, and check the host’s console for the lines in step 4. If you see [CrewSwap] round start, active: true, it is running.

Client install

To switch between crew members, you need client-side Lua for Barotrauma (LuaCs). CrewSwap itself downloads automatically when you join a server that runs it.

1. Install client-side LuaCs

  1. Subscribe to Lua for Barotrauma on the Steam Workshop.
  2. Follow the install instructions on that Workshop page. The current method is a Steam launch option. You add the launch option shown on the Workshop page to Barotrauma’s launch options in Steam (Library, right-click Barotrauma, Properties, Launch Options). It installs client-side LuaCs when the game starts. Copy the option exactly from the Workshop page. It can change between LuaCs versions, so it isn’t reproduced here.
  3. Keep the Lua for Barotrauma content package disabled in the in-game mod list. You need the subscription for the installer, but you shouldn’t tick the package as an enabled mod.

macOS: the launch-option installer doesn’t support macOS. See the Workshop page for the current state of macOS support.

The older method

Before the launch option existed, you installed client-side LuaCs by hosting a server with the LuaCs-patched executable and running install_cl_lua in its console. If the Workshop page still describes this for your platform, it works too. Prefer whatever the Workshop page currently recommends.

Do I need to enable C#?

Not for CrewSwap. CrewSwap’s client half is pure sandboxed Lua and ships no C#. If the game asks you to enable C# for the session, the request comes from a different mod on that server.

2. Get CrewSwap

You don’t need to do anything. When you join a server that runs CrewSwap, the game offers to download it like any other server mod. Accept the download.

You can also subscribe to CrewSwap on the Steam Workshop if you like. If your copy differs from the server’s, the game uses the server’s version for that session.

3. Check that it’s working

Join the server, then open the in-game console with F3. You should see:

[CrewSwap] client loaded build <build>
[CrewSwap] client: hello sent (1), probes ok
  • A second line like hello sent (2) a few seconds later is fine. The client retries until the server answers.
  • probes FAILED, or a [CrewSwap] client: patch ... failed, crewList access failed or missing <class> line, means CrewSwap couldn’t set up its crew-list hooks on your client. Usually your LuaCs build doesn’t match. See Troubleshooting.
  • No [CrewSwap] lines at all means client-side LuaCs isn’t running.

If it isn’t working, you’ll get one of these chat messages: the first once per connection, about 90 seconds after you first enter a round while CrewSwap is active, the second as soon as you join:

MessageWhat to do
[CrewSwap] This server uses CrewSwap. Turn on client-side LuaCs (LuaCs settings), then rejoin to switch between crew.Client-side LuaCs isn’t installed or isn’t active. Go back to step 1, then rejoin.
[CrewSwap] Your copy of CrewSwap is out of date or didn't start properly — rejoin to update.Your copy differs from the server’s, or failed to start. Rejoin. If it keeps happening, update LuaCs and unsubscribe/resubscribe CrewSwap.

You can keep playing normally either way. You just can’t switch until it’s fixed.

Now read Playing.

Playing

This page is for players. It assumes client-side LuaCs is installed (Client install) and the server runs CrewSwap in a multiplayer campaign.

A few words first

  • Your own character (your home) is the one you created or were given for this campaign. It is still yours, and the save stores it as yours.
  • Every other crew member is a bot. They are the crew in the crew list, including any you hire.
  • A crew member is Free for you when it is alive, conscious, nobody is playing it, and the server’s settings let you take it (see pool and protect).

Switching

Open the crew list, the vanilla list of crew portraits on the left side of the screen, and click a Free crew member’s row. The server checks the request. Within a moment you are playing that character, and you see:

[CrewSwap] Now playing <name>

The character you left becomes a bot again right away. It keeps its inventory and its orders, pauses for a moment, and carries on with what it was doing.

CrewSwap tidies up after each switch the way singleplayer does: it closes the command wheel, drops any item you were dragging, refreshes an open Talents tab, and updates the hints.

What the rows mean

Row looks likeMeaningClicking it
Normal coloursFree for youSwitches you into that character
Greyed out, with a player’s name on the rightThat player is playing itNothing from CrewSwap; the game’s usual click behaviour
Normal row, dimmed nameA crew member you can’t take right nowYou get a chat line saying why (for example, it belongs to another player)
Your own current characterYouThe game’s usual click behaviour

Dragging rows to reorder them still works. A drag doesn’t count as a click, so it never switches you. The order (command) button on each row works as usual too, so you can give orders to bots without taking them over.

Cooldown

You can switch once every 3 seconds. Click too soon and you get:

[CrewSwap] Too fast, wait 2 more second(s) before switching again.

When you can’t switch

MessageWhy
[CrewSwap] Finish the conversation before switching characters.You’re in a conversation dialogue.
[CrewSwap] You can only switch characters while a round is running.The round is ending, or hasn’t started.
[CrewSwap] Hold on, you are still joining the round.You haven’t finished joining or syncing yet.
[CrewSwap] You can switch once your own character has joined the crew (after you first spawn).You’re new to this campaign and haven’t spawned yet.
[CrewSwap] You cannot switch characters while spectating.You chose to spectate in the lobby.
[CrewSwap] Traitors cannot switch characters.You’re the traitor this round.
[CrewSwap] Someone else is already playing that character.Another player took it first, possibly in the same instant.
[CrewSwap] That character is dead, unconscious or otherwise unavailable.It isn’t in a state you can take.
[CrewSwap] That character belongs to another player.Pool or protect settings stop you taking it.
[CrewSwap] That is not a crew member you can switch to.It isn’t part of your crew (for example, an outpost NPC).
[CrewSwap] CrewSwap is off right now.CrewSwap is inactive (type !crewswap status to see why), or your copy is out of date or failed to start. In that case you’ll also have seen “Your copy of CrewSwap is out of date…”; update it and rejoin.

A click during a camera transition (for example, the end-of-level cinematic) is quietly ignored, because the transition would undo the switch.

A downed character doesn’t stop you. You can switch out of a body that is unconscious or dying, just like in singleplayer.

Who you can take

The server admin sets two options (see Admin commands). !crewswap status shows them to anyone.

Home protection on (the default): other players can’t take your own character, and you can’t take theirs. Bots are fair game.

Home protection off: while you’re playing someone else, your own character is Free for the others, and theirs are Free for you. A player who leaves mid-round leaves their body as a bot, and with protection off others can take it for the rest of that round. From the next round, characters of players who are offline don’t spawn at all (vanilla behaviour).

Pool shared (the default): any Free crew member can be taken, apart from protected homes.

Pool own: each bot belongs to a player.

  • A bot you hire while the pool is own is yours.
  • A bot nobody owns yet (the starting crew, or anything hired before own mode was turned on) belongs to the first player who plays it while the pool is own.
  • You can always take your own character.
  • In own mode, another player’s own character is off limits. The only exception: with protection off, an admin has made you its owner with !crewswap owner.
  • Admins can reassign a bot with !crewswap owner.

Changing these settings never kicks anyone out of the character they’re playing. It only affects new switches.

When your character dies

When the character you’re playing dies, the crew list updates and you can click any Free crew member to keep playing straight away.

  • The death counts against the character that died, not you. Any death penalty or permanent death applies to that character. Being the one who drove it doesn’t make you a spectator.
  • The body stays where it died, so you can go back for the gear.
  • Nobody Free? (MidRound respawn) Then the usual vanilla respawn brings back your own character. If your own character is still alive but someone else is playing it (protection off), you wait until somebody Free turns up.

Everyone is down

Normally the round ends soon after every player’s character is dead or down. With CrewSwap, if there are still Free crew members, the round is held open for 2 minutes so someone can switch in:

[CrewSwap] Everyone is down. Pick a crew member from the crew list within 2 minutes or the round ends.
[CrewSwap] 30 seconds left to pick a crew member.
[CrewSwap] Nobody switched in — the round will end.

Switching into anyone cancels the countdown. If nobody does, the round ends as vanilla would.

Bots who die come back

Outside Permadeath, a crew member who dies is not gone for good, unlike vanilla bots. They come back as a bot at the start of the next round:

  • after a saved round end, with vanilla’s usual death penalty to their skills
  • after an unsaved round end (a failed round, or a vote to end), with no penalty, because the round is rolled back anyway

Characters you fire are still removed, as in vanilla.

Permadeath

With Permadeath on (and Ironman off):

  • Every crew death is permanent: bots, your own character, or another player’s character, whoever was playing it.
  • If your own character dies for good, the next crew member you take who isn’t another player’s character becomes your new character. It is stored as yours from then on.
  • The game’s “take over a bot” prompt after death still works. CrewSwap handles it as a normal switch. Hiring a new character from that prompt is allowed only once your own character is permanently dead (or when you have no character in this campaign yet). Otherwise you get [CrewSwap] You can only take on a new crew member once your own character is permanently dead.

Joining, leaving and rejoining

  • New to the campaign? You spawn as your own character as usual. Once you’ve spawned, you can switch.
  • Leaving mid-round: the character you were playing goes back to being a bot straight away and keeps working. Vanilla wouldn’t do that: it would leave the body frozen and kill it after a while. This also means disconnecting is no escape from a fight.
  • Rejoining:
    • with protection off: you go back into the character you last played, if it’s still Free
    • otherwise: you go back into your own character, if it’s Free
    • This happens as soon as you have finished loading into the round (CrewSwap waits up to 3 minutes, and never moves anyone while the round is ending). If it doesn’t, click a Free row to switch.

Round start

  • Protection on: everyone starts the round in their own character.
  • Protection off: everyone starts in the character they last played, if it is Free. Otherwise they start in their own character.

Round end, money and XP

  • Mission pay and round-end rewards go to the character you’re playing when the round ends. Crew nobody was playing get what vanilla bots get.
    • Exception: on save-and-quit, pay goes to the players’ own characters.
  • Just before the game saves, CrewSwap puts every player back in their own character, and the bots stay bots. This is what keeps the save clean.
  • Money belongs to the character. Each crew member has their own wallet and salary setting, and switching never moves money. A bot’s wallet is kept through saves. If the round ends without saving, everyone’s wallets go back to the last save, bots’ included.
  • XP and skills belong to the character too. Your own character only gets XP from what it does. While you play someone else, your own character is idle crew and gets mission XP and round-end talent effects like a bot does.

For the full list of changes from vanilla, see Differences from vanilla.

Admin commands

You control CrewSwap with chat commands typed in the normal in-game chat box. Some notes before the list:

  • Nobody else sees the commands. CrewSwap swallows every !crewswap command (the word !crewswap on its own or followed by a space), from anyone, so it never appears in public chat. A message like !crewswapper isn’t a command and shows as normal. The one exception: when CrewSwap is off because of a version mismatch (a different game version, sandboxed server Lua, or an unreadable game version), its chat commands are not loaded, so !crewswap messages show in chat and get no reply. Check the server console for OFF: version mismatch (see Troubleshooting).
  • Replies are private. Only the sender sees them. The exceptions are a settings change, which is announced to everyone, and a failed !crewswap check, whose problem count is announced to everyone.
  • Who counts as an admin: any client with the vanilla Manage settings permission (the Admin preset includes it). Anyone can use !crewswap status. Everything else is admin-only. Anyone else gets [CrewSwap] Only admins can change CrewSwap settings.
  • Commands and their keywords are case-insensitive. Put names that contain spaces in "double quotes". An unclosed quote gets the general usage line back.

Summary

!crewswap status | pool shared|own | protect on|off | owner "<character>" "<player>" | check
CommandWhoWhat it does
!crewswap or !crewswap statusEveryoneShows whether CrewSwap is active and the current settings
!crewswap pool sharedAdminAny Free crew member can be taken (default)
!crewswap pool ownAdminPlayers can only take their own character, crew they own, and crew nobody has claimed yet
!crewswap protect onAdminPlayers’ own characters can only be played by them (default)
!crewswap protect offAdminPlayers’ own characters join the pool while their owner plays someone else
!crewswap owner "<character>" "<player>"AdminGives a crew member to a player (for own mode)
!crewswap checkAdminRuns the integrity check now and lifts an integrity lockdown if it passes (with a FAIL also on record, the lobby hold is released, so rounds can start again, but CrewSwap stays off (switching disabled) until the FAIL is fixed and the server restarted)

An admin who types a malformed command gets a usage line back (other players get the admin-only message), for example:

[CrewSwap] Usage: !crewswap pool shared|own
[CrewSwap] Usage: !crewswap protect on|off
[CrewSwap] Usage: !crewswap owner "<character>" "<player>"
[CrewSwap] Usage: !crewswap status | pool shared|own | protect on|off | owner "<character>" "<player>" | check

!crewswap status

[CrewSwap] CrewSwap is active. Crew pool: shared. Home protection: on.
  • inactive right now in place of active means CrewSwap is loaded but not running. This is normal before a campaign is loaded, in a non-campaign round, or under Ironman. It also shows during an integrity lockdown.

  • If CrewSwap has switched itself off, status gives the reason instead:

    [CrewSwap] CrewSwap is off: <reason>. Fix it and restart the server; !crewswap check cannot turn it back on.
    

    See Troubleshooting.

!crewswap pool shared|own

Sets who can take which bots. Everyone sees one of:

[CrewSwap] Crew pool is now "shared": any Free crew member can be taken.
[CrewSwap] Crew pool is now "own": you can only switch into characters you hired or that nobody has claimed.

shared (default): any Free crew member, apart from other players’ characters while home protection is on.

own: a player may take:

  • their own character
  • bots they own
  • bots that nobody owns yet. Taking one claims it, and the console logs switch: <player> claimed <character>.

Ownership comes from three places:

  1. Hiring while the pool is own. The hirer owns the new crew member. The console logs admin: <player> hired <character> and now owns them. Hires made while the pool was shared are not owned. Once the pool is own, whoever plays them first claims them.
  2. First play, for anything unowned, as above.
  3. !crewswap owner, below.

A replacement character bought in Permadeath becomes the buyer’s own character, not a hire.

In own mode another player’s own character is never takeable. The only exception: with protection off, an admin has made you its owner with !crewswap owner (see the note below).

!crewswap protect on|off

[CrewSwap] Home protection is now on: players' own characters cannot be taken by others.
[CrewSwap] Home protection is now off: players' own characters can be taken when they are Free.
  • On (default): each player’s own character can only be played by that player.
  • Off: while a player is playing someone else, their own character is Free for others. This also changes round start and rejoin: players go back into the character they last played, if it’s Free.

Changing either setting takes effect immediately. The crew list updates for everyone, and nobody is pushed out of the character they’re currently playing.

If the setting can’t be written to disk, you also see [CrewSwap] Could not save the setting to disk; it applies until the server restarts.

!crewswap owner "<character>" "<player>"

Makes a player the owner of a crew member. This matters in own pool mode.

!crewswap owner "Kim Lee" "Alice"
[CrewSwap] Kim Lee now belongs to Alice.
  • Names are matched case-insensitively: first an exact match, then a unique prefix. So !crewswap owner kim ali works if only one crew member starts with “kim” and only one player starts with “ali”.
  • The character must be in the current round’s crew, and the player must be connected.
  • CrewSwap must be active (a campaign round is running).
  • Ownership is saved with the character in the campaign save.

Possible replies:

ReplyMeaning
[CrewSwap] No crew member named "<name>".No match. Check the spelling, or wait until that character has spawned.
[CrewSwap] More than one crew member matches "<name>".Type more of the name.
[CrewSwap] No connected player named "<name>".The player isn’t online.
[CrewSwap] More than one player matches "<name>".Type more of the name.
[CrewSwap] That crew member has no CrewSwap record yet.Rare. Try again after the next save.
[CrewSwap] CrewSwap is off right now.Only works while CrewSwap is active.

You can give a player’s own character to someone else this way. With protection off and the pool set to own, the new owner can then take it while it’s Free. That is usually not what you want.

!crewswap check

Runs CrewSwap’s integrity check on the loaded campaign right away. It checks that every crew member is stored exactly once and that nobody has gone missing (see Troubleshooting).

ReplyMeaning
[CrewSwap] The integrity check passed.All good. If an integrity lockdown was in force, it’s lifted: switching works and rounds can start again.
[CrewSwap] The integrity check failed. See the server console for the details.Still a problem. The console lists each one, and everyone sees [CrewSwap] Integrity check failed (manual): N problem(s), see the server console.
[CrewSwap] The integrity check passed, but CrewSwap is still locked down (see the server console).The lockdown isn’t (only) an integrity one: CrewSwap has switched itself off with a FAIL (a patch that didn’t install, a module that didn’t load, a failed audit). Any integrity alarm is cleared, but CrewSwap stays off until the problem is fixed and the server restarted.
[CrewSwap] The integrity check is not available.The safety module didn’t load. Look for a FAIL line in the console.

!crewswap check only lifts integrity lockdowns. It can’t turn CrewSwap back on after a FAIL (a patch that didn’t install, a module that didn’t load). If an integrity alarm and a FAIL are both in force, a passing check clears the integrity alarm only, replies with the “still locked down” line above, and CrewSwap stays off.

The config file

The pool and protect settings are server-wide (shared by every campaign on the server). They are saved to:

<save folder>/CrewSwap/config.txt

<save folder> is the path printed at start-up as [CrewSwap] save folder: <path>. It’s the server’s Barotrauma data folder, the one that also holds Multiplayer/. The file is plain text:

pool=shared
protect=on
  • Values are lowercase (shared, own, on, off); anything else falls back to the default. (The chat commands, by contrast, are case-insensitive.)
  • Defaults: pool=shared, protect=on. If the file is missing, or a line is unreadable, the default is used for that setting.
  • CrewSwap reads the file once, at server start. The console shows [CrewSwap] admin: config: pool=shared protect=on (from file), or (defaults) when there is no file. To edit it by hand, stop the server first. Otherwise just use the chat commands, which write the file for you.

Other files in the save folder

PathWhat it is
<save folder>/CrewSwap/config.txtThe settings above
<save folder>/CrewSwap/selftestIf this (empty) file exists, CrewSwap runs its in-game self-test at start-up (see Testing). Delete it afterwards.
<save folder>/CrewSwap/force-integrity-failA developer test switch that makes every integrity check fail (so saves are blocked and CrewSwap locks down). Never leave it in place; delete it if present.
<save folder>/Multiplayer/<campaign>_CrewSwap.xmlA recovery copy of CrewSwap’s per-character data, written next to each campaign save. Leave it alone. The save itself is the source of truth.

CrewSwap’s per-character data (ids, owner, last player, bot wallets) is stored inside the campaign save, so it is saved, reverted and backed up together with vanilla’s data. See Persistence.

Differences from vanilla

Everything on this page applies only while CrewSwap is active: a multiplayer campaign round, Ironman off, and a matching game version. (A different LuaCs build only logs a warning; see Troubleshooting.) When CrewSwap is inactive (no campaign, Ironman, or a version mismatch), the game plays as vanilla. The exceptions:

  • the 20-backup count (see Saving and backups), which applies whenever CrewSwap is loaded on a matching game version, even when it is inactive;
  • the !crewswap chat commands, which are handled whenever CrewSwap is loaded, except on a version mismatch (see Chat);
  • during an integrity lockdown, the save safeguards described in Troubleshooting: the server is held in the lobby and saves still keep CrewSwap’s data.

With nobody switching, a CrewSwap campaign plays almost exactly like vanilla. Most of the differences below only show up once players start moving between characters, or when crew die.

Switching and control

  • Players can take control of Free crew members by clicking their row in the crew list (Playing). Vanilla only allows this through the cheat-flagged control command, which also damages the save.
  • Switching gives no free rename and never moves money between characters.
  • Player bodies are AI-capable characters. Vanilla spawns players’ characters as plain characters with no AI, so a body a player left would just stand there. CrewSwap spawns every campaign crew body with an AI that is paused while a player drives it. Two side effects:
    • Attacks made by players aren’t written to the server’s attack log the way vanilla logs player attacks.
    • Nearby bots react to an attack on a player’s body the way they react to an attack on a bot.
  • Your idle character is crew. While you play someone else, your own character is a bot for the round. It gets mission XP and round-end talent effects as bots do. An admin with the vanilla permission to manage bot talents can edit its talents like a bot’s.

Round start, joining and leaving

  • With home protection off, players start each round in the character they last played, if it’s Free, instead of always in their own character.
  • Disconnecting hands the body back to the AI right away. Vanilla leaves a disconnected player’s body frozen, then kills it after a timeout. With CrewSwap the character keeps working.
  • Rejoining puts you back in the character you last played (protection off) or your own character, if Free. Vanilla returns you only to a body you still control.
  • New players can switch only after their first spawn in the campaign.

Death

  • Death effects go to the character that died, not to whoever was driving it. In vanilla, permanent death and the skill penalty are keyed on the player driving the body. With CrewSwap they apply to the character itself, whether that’s a bot, your own character, or another player’s. (The “Retain Experience” talent effect is switched off instead; see Money and XP.)
  • A death never makes you a spectator. You can switch to another Free crew member straight away.
  • A borrowed body stays in the world when vanilla respawns you (outside Permadeath). It isn’t despawned along with you.
  • Dead crew members come back (outside Permadeath). Vanilla deletes every dead bot at round end, for good. CrewSwap keeps them and brings them back as bots at the start of the next round, with health reset and orders cleared:
    • after a saved round end they also take vanilla’s usual death penalty to skills. After a level transition their items are in the duffel bag left where they died. After a save-and-quit they keep them.
    • after an unsaved round end there is no skill penalty.
    • Fired crew are still removed as in vanilla.
  • The round doesn’t end while someone could switch in. Vanilla ends the round shortly after every player is dead or down. CrewSwap holds it open for up to 2 minutes while Free crew remain, and announces this in chat at the start, with 30 seconds left, and at the end. If nobody switches in, the round ends as vanilla would.

Permadeath

  • Every crew death is permanent, including the death of a player’s own character that the AI was running while its owner played someone else. Vanilla only applies permanent death through the player driving the body.
  • A permanently dead player’s character’s wallet goes to the campaign bank at the moment of death.
  • A player whose own character is permanently dead makes the next crew member they take (one that isn’t another player’s character) their new character.
  • The vanilla “take over a bot” request is handled as a CrewSwap switch for crew already in the world. Hiring or bench take-overs from that prompt reach vanilla only once the player’s own character is permanently dead (or when the player has no character in this campaign yet). Otherwise CrewSwap refuses them, because vanilla would discard the player’s living character.

Money and XP

  • Every character has their own wallet, bots included. Vanilla only keeps wallets for players’ characters, so a bot’s money is lost. CrewSwap saves bots’ wallets and salary settings with the campaign.
  • Bots’ money follows the save. A round that ends without saving gives bots back their last saved wallets, as it does for players’ characters.
  • Mission pay goes to the character each player is playing when the round ends.
    • On save-and-quit it goes to the players’ own characters instead, because they’re restored before the pay is handed out on that path.
  • The “Retain Experience” talent effect is off (RetainExperienceForNewCharacter). Vanilla saves the XP of whatever body a player drives when it dies, and gives it to that player’s next new character. With switching, that would hand a bot’s XP to the player’s own character. While CrewSwap is active nothing is saved, and any XP saved for this from before reads as zero. A character always keeps its own XP.

Saving and backups

  • Before every save, every player goes back into their own character, and borrowed characters stay bots. The save is therefore always in the shape vanilla expects.
  • Integrity check before writing. If a save would lose or duplicate a crew member, CrewSwap refuses to write it, reloads the last good save, and holds the server in the lobby until an admin runs !crewswap check. See Troubleshooting.
  • 20 rolling save backups instead of 3. Unlike everything else on this page, this applies to every campaign on the server whenever CrewSwap is loaded on a matching game version, including Ironman and non-campaign sessions where CrewSwap is otherwise inactive.
  • Extra data in the save. CrewSwap adds crewswap_* attributes to each character and a <CrewSwap> element to the campaign. Vanilla ignores them. It also writes a <campaign>_CrewSwap.xml recovery file next to the save.

Chat

  • !crewswap commands (the word !crewswap on its own or followed by a space) never appear in public chat (Admin commands), except when CrewSwap is off because of a version mismatch: then its commands aren’t loaded.
  • CrewSwap shows short [CrewSwap] chat lines: switch confirmations (shown by your own client) and denials, the 2-minute countdown, settings changes, integrity alarms and lobby notices, and notices when it is off or your client can’t switch.

Troubleshooting

Every message CrewSwap prints starts with [CrewSwap], both in chat and in the server console. When something looks wrong, check the server console first.

Quick lookup

You seeGo to
CrewSwap is off until it's updated for this game versionVersion mismatch
CrewSwap is off until an admin restarts the serverSwitched itself off
Integrity check failed (...), Integrity check failed: ... or The round can't start: an integrity check failedIntegrity alarm
This server uses CrewSwap. Turn on client-side LuaCs ...Client LuaCs missing
Your copy of CrewSwap is out of date ...Out-of-date client
CrewSwap is off in Ironman campaigns.Ironman
WARNING: built for LuaCs ... (console)Different LuaCs build
Clicking rows does nothingClicks do nothing

CrewSwap is off for this game version

Chat, at every round start:

[CrewSwap] CrewSwap is off until it's updated for this game version (<reason>).

Server console, at start-up:

[CrewSwap] OFF: version mismatch (<reason>)

There’s no patches installed line after it. <reason> is one of:

ReasonMeaning
game 1.14.0.0 (for example)The server runs a different Barotrauma version than this CrewSwap release was built for.
server Lua is sandboxedC# is disabled for the server in LuaCs’s settings. CrewSwap needs unsandboxed server Lua.
version read failed: ...CrewSwap couldn’t read the game version. (An unreadable LuaCs build only gives the warning in Different LuaCs build.)

A different LuaCs build is not on this list: it only logs a warning (see Different LuaCs build).

In this state CrewSwap’s chat commands are not loaded either: !crewswap messages (including !crewswap status) show in public chat and get no reply. Check the server console for the OFF: version mismatch line instead.

Your campaign is safe. In this state CrewSwap installs no patches at all, so the game runs pure vanilla code. Vanilla writes the saves without CrewSwap’s extra data (owners, last players, bot wallets). CrewSwap’s recovery file next to the save is left alone, and when a matching CrewSwap version runs again it re-matches its records from that file. Crew it can’t match unambiguously (for example, two with the same name) start fresh. If fewer than half of the crew can be matched, CrewSwap treats the recovery file as belonging to another campaign and starts everyone fresh.

To fix it: install the CrewSwap release made for your game version. If none exists yet, wait for one. For server Lua is sandboxed, set LuaCs’s C# run policy on the server back to its default.

Don’t just edit the expected versions in the mod’s files to make the message go away. The version gate exists because each game update can change the code CrewSwap patches. Contributing describes how a new version is checked properly.

Different LuaCs build

Server console, at start-up:

[CrewSwap] WARNING: built for LuaCs <expected>, running on <found>; continuing (patch audit and self-tests still guard)

<found> is unknown if the LuaCs build couldn’t be read. CrewSwap was tested against one LuaCs build (listed in Requirements), but LuaCs updates itself through the Workshop, so a different build doesn’t switch CrewSwap off. It keeps going, and the other checks still protect the campaign: if any patch fails to install, or one of the start-up or runtime checks fails (the live crew-list check on the first campaign round, the AI-body audit, the required game API checks), CrewSwap switches itself off as usual. The optional in-game selftest only reports problems in the console; it doesn’t switch CrewSwap off. So “self-tests” in the warning line means those always-on start-up and runtime checks, not the optional selftest.

If you see this warning and something misbehaves, put back the LuaCs build listed in Requirements if you can, and include the warning line when you report it.

CrewSwap switched itself off (FAIL)

Chat, when it happens and at every round start until the server restarts:

[CrewSwap] CrewSwap is off until an admin restarts the server (see the server console).

Server console:

[CrewSwap] FAIL: <reason>

!crewswap status also shows the reason (unless the failing module is CrewSwap’s admin module itself or one it depends on, in which case !crewswap gets no reply: read the FAIL line in the console).

CrewSwap does this when it finds something it can’t safely work around. Common reasons:

  • patch crewswap.<name> (...): a game method CrewSwap patches has changed. This usually means a game hotfix: the version gate accepts any 1.13.4.x revision, but a hotfix can still change a method.
  • loading crewswap.server.<module>: ...: one of CrewSwap’s files failed to load. A damaged or partial install is the usual cause.
  • non-AI body for <player> (or non-AI body (respawn) for <character>): a player’s body spawned, or respawned, without the AI it needs.
  • the crew list cannot be read: ...: the first campaign round couldn’t read the crew roster.
  • Other start-up checks, for example could not raise SaveUtil.MaxBackupCount to 20, MultiPlayerCampaign.GameOver is not accessible or ImmutableHashSet`1 could not be registered: the game or LuaCs build has changed. Treat these like a patch failure.

Your campaign is safe. Switching stops, and saves still carry every bit of CrewSwap data it already had. No new switches happen and the save keeps its vanilla shape (the one exception: a crew member with no CrewSwap id yet may be given one when a save is loaded; see Persistence).

!crewswap check can’t lift this. That command only clears integrity lockdowns. If an integrity alarm and a FAIL are both in force, a passing check clears the integrity alarm but replies [CrewSwap] The integrity check passed, but CrewSwap is still locked down (see the server console).. The lobby hold is released, so rounds can start again, but CrewSwap stays off (switching disabled) until the FAIL is fixed and the server restarted.

To fix it:

  1. Read the FAIL line.
  2. If it looks like a one-off (for example a spawn check during an unusual round), restart the server and check the console for patches installed: N/N and no FAIL lines.
  3. If it comes back, reinstall CrewSwap. If it still fails, please report it with the console lines.

Integrity alarm and lockdown

At every saved round end (a level transition or save-and-quit), CrewSwap checks the crew twice before the save is written. It checks that:

  • no crew member is stored twice (as both a player character and a bot, or twice in one place)
  • every crew member from the start of the round is still there, unless they died permanently (Permadeath), were fired, or were replaced on purpose (a vanilla “create new character”, or a bot taking over as a Permadeath player’s new character)
  • CrewSwap’s own records only refer to crew members that exist

It also checks each save as it’s loaded, for the same crew member appearing twice.

What you see

Every failed save-time or manual check first posts this to everyone (<when> is pre-save or post-save; a manual !crewswap check uses manual, and the optional selftest’s integrity check uses selftest):

[CrewSwap] Integrity check failed (<when>): <N> problem(s), see the server console.

After a round end:

[CrewSwap] Integrity check failed: the round was not saved and the campaign was reloaded from the last good save. Switching and new rounds are off until an admin runs !crewswap check.

After loading a save (this check doesn’t post the count line above; the console shows [CrewSwap] INTEGRITY FAIL (load): duplicate ... lines and an INTEGRITY ALARM: line instead):

[CrewSwap] Integrity check failed: this save has the same crew member twice. Switching and new rounds are off until an admin runs !crewswap check or loads a backup.

If anyone tries to start a round while it’s locked:

[CrewSwap] The round can't start: an integrity check failed. An admin must run !crewswap check.

The server console lists each integrity problem:

[CrewSwap] INTEGRITY FAIL (<when>): <problem> <id> (<name>)
ProblemMeaning
duplicateThe same crew member is stored more than once.
missingA crew member from the start of the round is gone, without a permanent death or firing to explain it.
movedA bot ended up stored as a player character, or the other way round.
unknownCrewSwap has a record of a crew member that no longer exists.

If there are no INTEGRITY FAIL lines, look for a [CrewSwap] roundend: ... error: ... line (for example roundend: restore error: ... or roundend: pre-save hook 1 error: ...) or [CrewSwap] roundend: save blocked: a bot wallet could not be read just before the alarm. The save was blocked for that reason instead. What happened and the recovery are the same.

What happened

The check failed before anything reached the disk, so CrewSwap didn’t write the save. It reloaded the campaign from the last good save, the same way vanilla handles a failed round. The round that just ended is lost, but nothing bad was saved.

If the console shows [CrewSwap] safety: INTEGRITY: reload failed: ..., the automatic reload didn’t work: load a backup from the lobby as in step 3 below.

While locked down:

  • switching is off
  • no round can start, so the server stays in the lobby (vanilla would start the next round straight away)
  • saves still keep all of CrewSwap’s data

What to do

  1. An admin types !crewswap check in chat.
  2. If it says [CrewSwap] The integrity check passed., the lockdown is lifted. Start the round from the lobby as usual.
  3. If it keeps failing, load a backup:
    • CrewSwap raises the game’s rolling save backups from 3 to 20 per campaign.
    • In the lobby, open the campaign load list, find this campaign’s backups and pick an earlier one.
    • Run !crewswap check again, then start the round.
  4. Please report it. An integrity failure always means a bug, in CrewSwap or in how it interacts with something else.

Client-side LuaCs missing

A player sees this once per connection, about 90 seconds after they first enter a round while CrewSwap is active:

[CrewSwap] This server uses CrewSwap. Turn on client-side LuaCs (LuaCs settings), then rejoin to switch between crew.

The server console logs [CrewSwap] switch: <player> sent no hello (no client-side LuaCs?).

Their game isn’t running CrewSwap’s client half, almost always because client-side LuaCs isn’t installed or isn’t active. They can keep playing normally, without switching. To fix it, follow Client install and rejoin. Checklist:

  • Client-side LuaCs installed with the method on the Workshop page.
  • The Lua for Barotrauma content package is disabled in the in-game mod list.
  • After joining, the F3 console shows [CrewSwap] client loaded build ....

Out-of-date client

[CrewSwap] Your copy of CrewSwap is out of date or didn't start properly — rejoin to update.

The player’s copy of CrewSwap is a different build from the server’s, or its crew-list hooks couldn’t be installed. Switching stays off for that player until it’s fixed:

  • Rejoin. The server sends its current version on joining.
  • After a CrewSwap update, the server must be restarted before it sends the new version.
  • If the F3 console shows probes FAILED, the player’s LuaCs build likely differs from what CrewSwap expects. Update client-side LuaCs.

Ironman

[CrewSwap] CrewSwap is off in Ironman campaigns.

This appears once per round. It’s expected: CrewSwap doesn’t support Ironman yet and stays off, so Ironman plays exactly as vanilla. Ironman support may come in a later version.

Clicking a row does nothing

  • Is CrewSwap active? Type !crewswap status. It must say active.
  • Is it a campaign round? CrewSwap is off in sandbox, mission and PvP modes.
  • Is your client running it? Press F3 and look for [CrewSwap] client loaded build and hello sent (...), probes ok.
  • Is the row actually Free? Greyed rows with a name are taken. A dimmed name means you can’t take it, and clicking should tell you why.
  • Camera transition? Clicks during a cinematic are ignored on purpose.

Row colours look wrong

Row greying and labels are cosmetic. They come from the server’s crew state, which is sent on switches, deaths, round start and settings changes (not when a bot goes unconscious or wakes up, or is hired mid-round). If a row looks stale, it usually corrects itself on the next switch or death. The server is always the one that decides whether a switch is allowed.

The round won’t end

If everyone is down but Free crew are left, CrewSwap holds the round open for up to 2 minutes so someone can switch in. Watch chat for the countdown. See Playing.

Reporting a problem

Please open an issue on GitHub and include:

  • the CrewSwap version, the game version and the LuaCs build (from the [CrewSwap] running on game ... console line)
  • the respawn mode, and the pool and protect settings (!crewswap status)
  • every [CrewSwap] line from the server console around the problem
  • from players, the [CrewSwap] lines from their F3 console
  • what you did and what you expected

Before posting logs, remove anything personal: IP addresses, server passwords, and player names if you’d rather keep them private.

FAQ

Does every player need LuaCs?

Every player who wants to switch needs client-side LuaCs. Players without it can still join and play their own character as normal. They just can’t switch, and they get one chat message telling them how to turn it on. The server needs server-side LuaCs. See Requirements.

Do I have to subscribe to CrewSwap on the Workshop?

No. The server sends CrewSwap to you when you join. Subscribing on the Steam Workshop is optional.

Will it ask me to enable C#?

Not because of CrewSwap. It’s pure Lua and ships no C# code; its client half runs sandboxed.

Can I add CrewSwap to a campaign that’s already running?

Yes. CrewSwap gives every crew member a stable id the first time it saves the campaign, then carries on from there. Before that point, vanilla rules applied. For example, bots that died earlier are already gone.

Can I remove it again?

Yes. Finish on a saved round end (a level transition or save-and-quit), stop the server and disable the mod. Before every save, CrewSwap puts every player back in their own character, so the save is in vanilla shape and vanilla loads it normally. Vanilla ignores CrewSwap’s extra data. From then on vanilla rules apply again: dead bots are deleted, and bots’ wallets aren’t kept. See Server install.

What happens when Barotrauma or LuaCs updates?

Barotrauma: CrewSwap notices the new game version at server start and switches itself off. Nothing is patched, and the campaign plays as vanilla until a matching CrewSwap release is out. Saves made meanwhile are plain vanilla saves; when CrewSwap is back it restores its records from its recovery file next to the save, apart from any crew it can’t match unambiguously. See Troubleshooting.

LuaCs: a different LuaCs build only gets a warning in the server console, and CrewSwap keeps running. If the update breaks something CrewSwap relies on, a patch fails to install or a start-up or runtime check fails, and CrewSwap switches itself off. See Different LuaCs build.

Does it work with Ironman?

Not yet. CrewSwap stays off in Ironman campaigns and says so in chat once per round.

Does it work in sandbox, mission or PvP mode?

No. CrewSwap is for the multiplayer campaign only.

Can I control outpost NPCs, escorts, monsters or the other team?

No. Only your own crew.

Can I use a key to cycle through characters?

Not in this version. Switching is by clicking the crew list.

Can I get a free rename or move money by switching?

No. Switching gives no rename, and money stays in each character’s own wallet.

Who gets the mission pay?

The character each player is playing when the round ends. On save-and-quit, the players’ own characters get it instead. See Playing.

My own character was idle all round. Does it fall behind?

Your idle character is crew like any bot. It gets mission XP and round-end talent effects the way bots do.

A bot died. Is it gone?

Outside Permadeath, no. It comes back as a bot at the start of the next round, with vanilla’s usual death penalty if the round was saved. In Permadeath, every death is permanent.

What if I disconnect while playing a bot?

The bot goes back to the AI straight away and keeps working. When you rejoin, you go back into your own character (or, with home protection off, into the character you last played), if it’s Free.

Can two players grab the same bot at once?

The server decides, and the first request wins. The other player gets [CrewSwap] Someone else is already playing that character.

Is it compatible with other mods?

Most content mods (items, subs, creatures) should be fine. Be careful with mods that also change the crew list, character control, or how the campaign saves its crew. Don’t combine CrewSwap with another crew-switching mod such as Multiplayer Crew Manager: the two would fight over the same save data.

Does it need cheats or disable achievements?

No. CrewSwap doesn’t use the control command or turn on cheats.

Is it safe for my campaign?

CrewSwap is built around not losing or duplicating characters. It checks the crew before every save, refuses to write a save that fails the check, and keeps 20 backups. That said, it is beta: live switching with several real players hasn’t been fully tested yet. Back up your Multiplayer saves before you start, and read Troubleshooting so you know how to recover.

Why is the server stuck in the lobby?

CrewSwap probably locked down after a failed integrity check. An admin should run !crewswap check. See Integrity alarm.

Can I host from the game instead of a dedicated server?

CrewSwap is built and tested for dedicated servers. In-game hosting hasn’t been tested. See Server install.

Was this made with AI?

Yes. CrewSwap was built with AI assistance (Claude), directed and reviewed by a real software engineer. It was made for playing with friends and is shared in case others enjoy it. If you’d rather not use AI-assisted mods, that’s completely fine. You don’t have to use it.

Where do I report bugs?

On GitHub. See Reporting a problem for what to include.

Architecture overview

This part of the book is for people who want to read, review or change the CrewSwap code. It assumes you know Barotrauma’s multiplayer campaign from the player side and have at least skimmed the LuaCs documentation. The player and admin guides describe what the mod does; these pages describe how and, just as importantly, why.

The code under mod/CrewSwap/ is the source of truth. If a page here disagrees with it, the code wins, and a pull request fixing the page is very welcome.

The problem

Vanilla multiplayer gives each player exactly one character. Outside Permadeath’s “take over a bot” prompt, the only built-in way to control a bot is the cheat-flagged control console command, which needs enablecheats (and so disables achievements). Worse, it corrupts the campaign: at round end MultiPlayerCampaign.SavePlayers saves the bot as the player’s own character, removes it from the crew, and erases the player’s real character.

CrewSwap’s job is to make switching feel like singleplayer while guaranteeing that the campaign save never loses or duplicates a character.

Goals

  • Switch by clicking a crew-list row. Mid-round, a player can take any Free crew member.
  • The body you leave keeps working. It becomes a bot again and carries on with its orders, inventory and what it was doing.
  • Everything belongs to the character. Skills, XP, talents, inventory, health and money stay with each character through switches, round ends, save-and-quit and server restarts.
  • Death is not the end of your turn. When the character you are driving dies, you can jump into another Free crew member.
  • Two toggles: the crew pool (shared or own) and home protection (on or off).

Anti-goals

These are failures even if switching “works”:

  1. Losing a levelled character. Nothing may drop a character from the save except permanent death (Permadeath) or firing. Outside Permadeath this is stricter than vanilla, which deletes dead bots.
  2. Duplicating a character. No character may be saved twice or spawn twice.
  3. Breaking normal play. With nobody switching, a campaign behaves like vanilla. Players without the mod are told why they cannot switch instead of silently desyncing.
  4. Free stuff from switching. No free renames, no money moved between characters and no XP inflation from talents that vanilla tracks per player.

Constraints

  • Pinned versions. The build targets Barotrauma 1.13.4.x and was tested against one LuaCs commit (EXPECT_GAME and EXPECT_LUACS in shared/build.lua). On a game-version mismatch the mod installs no patches and the campaign runs as plain vanilla; a different LuaCs commit only logs a warning, and the patch-install audit and self-tests are the guard (see Safety). Because of this, nothing the mod stores may ever be required to load or play a save.
  • Pure Lua, sandboxed on clients. No <Assembly>, no RunUnrestricted="true". Clients run sandboxed Lua unless they opt into C#; shipping any C# or unrestricted Lua forces a per-session prompt, and a player who declines silently runs without the whole package. Sandboxed Lua still has Hook.Patch, Networking and every auto-registered Barotrauma.* type, which is all CrewSwap needs. Server-side Lua runs unsandboxed (the version check relies on that). Client code works in both modes, because whether a client is sandboxed depends on its history.
  • The server is authoritative. Clients only ask. Every decision that touches the campaign is made, validated and logged on the server.
  • Admin control is in-game chat. Many hosting setups ignore the dedicated server’s stdin console, so all admin commands are !crewswap ... chat messages.

The core design: “home + pool”

CrewSwap keeps vanilla’s storage exactly as it is and only moves control during a round.

  • Each player’s home character stays where vanilla keeps it: in that client’s CharacterCampaignData (CCD), keyed by account id.
  • Every other crew member stays in the CrewManager roster (active crew plus the reserve bench).
  • During a round, any client may drive any Free crew body.
  • Before vanilla saves, the mod puts storage back into the shape vanilla expects: every client back in their own home body, borrowed bodies left as bots. Vanilla’s own save path then stays responsible for inventory, health, orders and skills.

This is the least invasive model that avoids losing or duplicating characters. An earlier C# mod for the same idea (Multiplayer Crew Manager) instead turned every crew member into a fake client CCD and suppressed crew-manager saving, which made campaigns hard to convert back to vanilla. “Home + pool” means a CrewSwap campaign is always a valid vanilla campaign: turn the mod off and the save simply loads.

Everything else in the mod exists to make that one restore safe:

               round running                         round ends
   +------------------------------------+   +-------------------------------+
   |  any client  --drives-->  any Free |   |  GameSession.EndRound         |
   |  crew body (AI-capable)            |   |   mission pay -> driven body  |
   |                                    |   |  CampaignMode.End             |
   |  vanilla storage untouched:        |   |   dead NON-home crew kept     |
   |    homes  -> CCDs                  |   |  MultiPlayerCampaign          |
   |    others -> CrewManager roster    |   |    .SavePlayers  (prefix)     |
   |                                    |   |   RESTORE: everyone home,     |
   |  death effects redirected to the   |   |   borrowed bodies -> bots     |
   |  character that actually died      |   |   integrity check (pre/post)  |
   +------------------------------------+   |  SaveUtil.SaveGame            |
                                            |   blocked if a check failed   |
                                            +-------------------------------+

Why every crew body is spawned AI-capable

Vanilla spawns a client’s own body as a plain Character with hasAi: false. A plain body has no HumanAIController, so when its player switches away it would stand still forever and never count as a bot. CrewSwap forces hasAi = true for campaign player spawns (a prefix on Barotrauma.Character.Create), so every crew body is an AICharacter. Vanilla already supports an AICharacter driven by a remote player: the AI is suspended while IsRemotePlayer is set and resumes on its own when control is cleared. An audit at round start and on every character.created checks that it really worked and switches the mod off if not.

The trade-off: player bodies are AI characters, so attacks made by players are not written to the server’s attack log, and nearby bots react to an attack on a player body as they would to one on a bot. Both are listed in Differences from vanilla.

Components

The Lua lives under mod/CrewSwap/Lua/crewswap/ and is split by where it runs and whether it touches the game:

 shared/   build.lua          version stamp + expected game/LuaCs versions
 core/     pure Lua: plain tables in, plain tables out. No game globals. Unit-tested.
           rules  registry  mirror  placement  grace  integrity  revive
           commands  statecodec  version
 server/   adapters: every server game call and every Hook.Patch
           main (autorun, version gate, CS.active)
           game (views of live objects)   patch (Hook.Patch wrapper)   selftest
           spawn  identity  switch  presence  roundend  death  crewdown  safety  admin
 client/   main.lua (autorun): crew-list click, row styling, housekeeping, messages

Data flows one way across the core/adapter boundary: an adapter reads live game objects, turns them into plain views (for example G.crewViews() in server/game.lua), asks a core module for a decision, then applies the decision to the game. That keeps every decision testable with stock lua5.2, without a game in sight. See Modules for each file and Testing for how this is exercised.

Global mutable state lives in two tables: CS on the server (set up by server/main.lua) and CSC on the client (set up by client/main.lua). Modules expose functions on these tables when another module needs to call them across a dependency edge (for example CS.sidOfInfo, CS.runCheck, CS.noteLegit).

The CS.active switch

Patches cannot be removed reliably at runtime (Hook.RemovePatch is broken in the targeted LuaCs build; see Patches). So instead of unpatching, every callback is gated on mod state: most on CS.active; the identity layer on CS.campaignOk (and the XML constructor / MultiPlayerCampaign.Load postfixes not at all, because they run before activity is known); the round-end restore on CS.active or CS.lockdown, so a lockdown save still gets the vanilla shape; and the safety/save-blocking patches on their own block and lock flags, so they still work during a lockdown. A few others gate on the state they handle (the kill postfix on a pending death context, the game-over postfix on a restore that already ran). The end-round prefix (crewswap.endround) sets CS.roundEnding ungated and uses the restore’s CS.active or CS.lockdown gate only for the home snapshot. The wallet commit (crewswap.wallets.save) is gated on the save’s own flags (isSavingOnLoading, CS.blockSave), with activity checked inside the wallet snapshot. Don’t “fix” these gates to CS.active: the differences are what keep saves safe. Cleanup callbacks that only lower flags or clear per-round state (crewswap.endguard.post, crewswap.endgame, crewswap.crewdown.post, and the safety revert/load postfixes) are deliberately ungated, so state never sticks after a lockdown.

The main flags:

CS.campaignOk = CS.versionOk and isMultiplayerCampaign and not ironman
CS.active     = CS.campaignOk and not CS.lockdown

CS.recomputeActive() in server/main.lua recomputes it at load, at every round start, after a campaign load and before every save. CS.campaignOk deliberately ignores a lockdown: while the mod is locked down, the identity layer still writes back the ids and records it already holds, so a vanilla save during a lockdown never throws away CrewSwap’s data.

Design decisions worth knowing up front

  • Ironman is off. Vanilla has no Ironman game-over; Ironman only adds an immediate per-character disk write keyed on the driver. Supporting it properly means writing bot deaths to disk mid-round and inventing a campaign-over state: new save-critical code for a mode the authors did not need. While Ironman is on, CrewSwap stays inactive for that campaign and says so in chat.
  • Money is per character. Vanilla only persists wallets for player CCDs. CrewSwap stores every bot’s balance and salary on its own record, so switching never moves money. See Persistence.
  • Non-home crew come back. Vanilla deletes every dead bot. Outside Permadeath, CrewSwap keeps a dead non-home crew member in the roster, applies vanilla’s death skill penalty, and has them spawn alive next round, just as a player’s character would.
  • RetainExperienceForNewCharacter is disabled. Vanilla tracks it per client, so with switching it would hand a borrowed body’s XP to the player’s next character.
  • The vanilla “take over a bot” request is routed through CrewSwap for crew already in the world, because vanilla’s TryTakeOverBot discards the client’s CCD, and after a switch that could be a living home.
  • No custom backup copies. Instead of copying save files around, the mod raises vanilla’s own rolling backup count to 20, which admins can already restore from the lobby.
  • Not kicking players without client-side LuaCs. They can still play the campaign as vanilla; they are told once how to enable switching.

Modules

Every Lua file in the mod, what it is responsible for, and the functions other code relies on. Paths are relative to mod/CrewSwap/.

Package files

FilePurpose
filelist.xmlThe content package: name="CrewSwap", modversion, gameversion="1.13.4.0", corepackage="false", and one <Item> entry for the sync file. modversion is stamped by tools/package.sh.
ModConfig.xmlTwo autoruns: Lua/crewswap/server/main.lua with Target="Server" and Lua/crewswap/client/main.lua with Target="Client". Because a ModConfig.xml exists, LuaCs skips the legacy Lua/Autorun folder scan. Everything else is reached through require.
Content/crewswap_sync.xmlAn empty <Items></Items> file. Lua files do not count as multiplayer-synced content, so without at least one synced file the server would neither send the mod to clients nor require it. An item file is synced, and an empty items root creates no prefabs and logs no errors.

All modules are namespaced crewswap.* because the require cache is shared by every mod.

shared/

shared/build.lua

Returns { BUILD, MODVERSION, EXPECT_GAME, EXPECT_LUACS }.

  • BUILD is the exact build string. The client sends it in its hello and the server compares it for equality, so server and clients must run copies from the same package.
  • MODVERSION is written into the save’s <CrewSwap> element.
  • EXPECT_GAME ('1.13.4') and EXPECT_LUACS (a short LuaCs commit hash) are what the version gate compares against. A game mismatch turns the mod off; a LuaCs mismatch only logs a warning.

In the repository, BUILD and MODVERSION hold the current release values ('0.9.0-beta' / '0.9.0'). tools/package.sh <version> [suffix] re-stamps both, plus modversion in filelist.xml, in a staged copy, so the zip always carries the version you pass.

core/: pure decision modules

No game globals, no side effects beyond their own tables. Each has a matching tests/test_*.lua.

core/version.lua

  • compare(found, expect) → ok, reason, warning. The game matches when it equals EXPECT_GAME or starts with EXPECT_GAME .. '.' (so 1.13.4.2 matches but 1.13.40 does not); a game mismatch returns false, 'game <found>', and takes precedence (no warning). LuaCs is compared on the first 7 characters of the commit hash: a different, short, empty or unreadable commit returns true, nil, warning, where warning is the line [CrewSwap] WARNING: built for LuaCs <EXPECT_LUACS>, running on <found or 'unknown'>; continuing (patch audit and self-tests still guard). A full match returns true, nil, nil.

core/rules.lua

The switching rules.

  • COOLDOWN = 3 (seconds between switches per player).
  • allowedByToggles(view, toggles, requesterKey): protect on hides other players’ homes; own pool allows only bodies the requester owns, their own home, or bodies that nobody owns and that are nobody’s home (the first player to take one claims it).
  • isFree(view, toggles, key): crew, an AI body, alive, conscious, not removed, no controller, and allowed by the toggles.
  • validate(req, view, toggles, ctx) → ok, reason. Reasons in fixed order: inactive, round (not running or ending), joining (not in game or not synced), nohome (no campaign character yet), spectating, traitor, dialogue, cooldown, invalid (not crew), taken, unavailable, not-yours. The requester’s own body state is deliberately ignored, so a downed player may still switch out.
  • message(reason, extra): the player-facing [CrewSwap] ... text for a reason (also used on the client, so both sides word denials identically). It also words the takeover denial, which is not a validate reason: the TAKEOVERBOT routing in server/death.lua sends it for a hire or bench id outside the Permadeath case.

core/registry.lua

Session-to-stable-id bindings and per-character records.

  • new() → registry with bySession (CharacterInfo.ID → sid) and chars (sid → record).
  • R:bind(sessionId, sid), R:sidOf(sessionId), R:record(sid) (creates on demand), R:prune(liveSessionIds) (drops stale bindings, keeps records).
  • FIELDS maps record fields to XML attributes: last, owner, home, wbal, wrd, lastAt → crewswap_last, crewswap_owner, crewswap_home, crewswap_wbal, crewswap_wrd, crewswap_lastat. NUMERIC marks wbal, wrd and lastAt. ID_ATTR = 'crewswap_id'.
  • encodeAttrs(rec) / decodeAttrs(map): record ↔ attribute strings.
  • campaignElement(state) / parseCampaignElement(map): the <CrewSwap campaignguid modversion> element.

core/mirror.lua

Re-matching after a Lua reload or a period with the mod off.

  • rowAttrs(row) / parseRow(get): one <c> row of the mirror file (id, on = original name, h = GetIdentifierUsingOriginalName() hash, job, plus the record’s crewswap_* fields).
  • match(entries, live) → { [sessionId] = sid }. Pass 1 pairs entries and live infos that share a unique hash; pass 2 does the same on original name for what is left. Anything ambiguous stays unmatched, so every sid is claimed at most once and the result does not depend on list order.

core/placement.lua

Where a player goes at round start or on rejoin.

  • lastPlayed(chars, key): the sid whose record has last == key and the greatest lastAt.
  • roundStart(players, views, toggles) → list of { key, targetId } moves. With protect on, nobody moves. With protect off, each player goes to the character they last played if it is Free for them, is not their home, and nobody earlier in the list claimed it.
  • rejoin(player, views, toggles) and rejoinTarget(...): the same rule for one rejoining player, falling back to their own home body when that is Free for them (a rejoiner whose body was handed back to the AI otherwise has no character).

core/grace.lua

The crew-down grace timer: GRACE = 120, WARN_AT = 90, and three chat lines (start, 30 s left, end). grace.new() returns a state with step(dt, vanillaDown, anyFree) → mask, announce. While vanilla would end the round and Free crew exists, mask is true for up to 120 s.

core/integrity.lua

The storage check over plain lists of sids.

  • snapshot(stores): copies the CCD and roster lists and records stale sids (registry records for characters already gone at the snapshot, i.e. removals an earlier round accepted).
  • check(startSnap, nowStores, legit) → ok, problems. Problems are sorted strings: duplicate <sid>, missing <sid>, moved <sid>, unknown <sid>. legit lists removals made on purpose this round (permadead, fired, discarded) and adopted bots that legitimately moved from roster to CCD. See Safety.

core/revive.lua

What happens to a dead crew member at round end.

  • plan(info, path): info = { cause = 'none'|'disconnected'|'other', permadeath, inCCD, inRoster }, path = 'transition'|'saveandquit'|'unsaved'. Returns flags keep, penalty, clearHealth, clearInventory, clearOrders, pendingSpawn. Only non-home roster members outside Permadeath that actually died are kept. The skill penalty applies only when the round saves and the death was not a disconnect. The inventory is cleared only on a level transition.
  • dropsAtSave(body): whether vanilla SavePlayers itself removes this body’s info (a dead bot with an inventory that did not die of a disconnect).

core/commands.lua

The admin chat command and config file.

  • PREFIX = '!crewswap', DEFAULTS = { pool = 'shared', protect = true }.
  • parse(text) → nil (not ours) or { op = 'status'|'check'|'pool'|'protect'|'owner'|'error', ... }. Tokens split on whitespace with "double quoted" parts kept whole; !crewswapper is not a match.
  • configEncode(t) / configDecode(s): the pool=... / protect=on|off text format; unknown lines and bad values fall back to defaults.
  • statusText(t, info), FAIL_NOTICE, and findByName(list, name) (case-insensitive exact match, then a unique prefix; 'none' or 'ambiguous' otherwise).

core/statecodec.lua

The crewswap.state payload: encode(writer, entries) and decode(reader). Layout: UInt16 count, then per entry UInt16 entity id, String controller name, Boolean free. See Networking.

server/: adapters

Loaded by server/main.lua in this order: game, patch, selftest, spawn, identity, switch, presence, roundend, death, crewdown, safety, admin. The order matters in a few places (noted below), because modules append to shared hook lists and patch the same methods.

server/main.lua (autorun)

  • Creates CS, carrying over the client hello tables (clientBuild, clientBad, joinedAt, noHelloTold) across a server Lua reload, since every client’s Lua keeps running.
  • CS.fail(reason): switch the mod off for the rest of the session (sets lockdown, records failReason, prints [CrewSwap] FAIL: <reason>, announces FAIL_NOTICE now and every round start).
  • The version gate (checkVersions, see Safety). On a game-version mismatch (or sandboxed server Lua) it registers a single roundStart chat notice and returns before any module loads. A different or unreadable LuaCs commit prints a [CrewSwap] WARNING: built for LuaCs ... line and boot continues.
  • CS.recomputeActive(campaign): computes CS.campaignOk and CS.active, and sends the Ironman notice once per round.
  • Loads the server modules (a missing module is skipped; any other load error is a CS.fail), prints patches installed: N/N, and schedules the in-game selftest when its marker file exists.

server/game.lua (G)

The game adapter everything else uses.

  • G.list(x) / G.iterate(x): converts any CLR collection (a copied table, an iterator function or a userdata enumerable such as a LINQ result) into a plain Lua array.
  • G.accountKey(client): AccountId.StringRepresentation, or 'addr:<address>' when there is no account id (matching how vanilla keys CCDs).
  • G.clients(), G.campaign() (the MultiPlayerCampaign or nil), G.isCrew(ch), G.controllerOf(ch), G.homeOf(ch), G.homeBodyOf(client) (the live body of the client’s CCD info, searched each time so it survives respawns and mid-round joins).
  • G.view(ch) and G.crewViews(): plain views { id, sid, alive, conscious, removed, crew, isAI, controller, controllerName, homeOf, owner, name } for the core modules. Crew means a Team1 human whose info is a CCD info (a home) or is in CrewManager:GetCharacterInfos(true) (bots and the bench). Stable ids are only minted while CS.active.
  • G.tell(client, text), G.announce(text), G.isAdmin(client) (ClientPermissions.ManageSettings), G.now() (Timing.TotalTime), G.isPermadeath(), G.toggles().
  • G.checkLiveList(): on the first campaign round, proves the real crew list converts; on failure the mod switches itself off (SELFTEST FAIL live-crew-list).

server/patch.lua (P)

P.patch(id, class, method, types, fn, when) wraps every server Hook.Patch in pcall. A patch that does not resolve means the game is not what the build was made for, so it calls CS.fail. P.errors[id] and P.summary() feed the selftest and the patches installed line.

server/selftest.lua

M.add(name, fn) registers an in-game check; M.run() prints SELFTEST PASS|FAIL|SKIP lines and a summary. See Testing.

server/spawn.lua

  • Prefix on Character.Create (crewswap.forceai): sets hasAi = true for remote-player spawns with an info, in a campaign.
  • S.isAIBody(ch): an AICharacter with a HumanAIController.
  • Audits on roundStart and character.created: a player body without AI in a multiplayer campaign is a CS.fail.

server/identity.lua

Stable ids and campaign state. See Persistence.

  • CS.sidOfInfo(info): the sid for an info, taken from the binding, from an id just read from XML (CS.pending), or freshly minted (CS.newGuid()).
  • CS.liveCrewInfos(campaign): bots and bench, every CCD info, and Ironman’s backup list.
  • M.writeMirror, M.readMirror, M.bindMissing(infos, allowMint).
  • Patches on CharacterInfo.Save, the CharacterInfo XML constructor, MultiPlayerCampaign.Load and MultiPlayerCampaign.Save (prefix and postfix), plus a reload-recovery block that runs at load time.

server/switch.lua (SW)

  • SW.request(client, ch, source): build the requester and context, rules.validate, then SW.switch; on denial SW.deny.
  • SW.switch(client, target): the switch routine (see Lifecycle).
  • SW.release(client): hand the client’s body back to the AI.
  • SW.sendState(client), SW.broadcastState(): per-client crewswap.state.
  • CS.isVoluntarySpectator(c): a lobby spectator, as opposed to one a permadeath kill set.
  • The crewswap.hello and crewswap.request receivers, the hello watchdog, and the “no client-side LuaCs” notice. See Networking.

server/presence.lua

  • client.disconnected (a prefix on vanilla’s disconnect, so client.Character is still set): record the client as last controller, then SW.release so the body reverts to AI instead of freezing.
  • Postfix on GameServer.OnInitializationComplete queues a joining client; a think poll (every 0.5 s, for up to JOIN_WINDOW = 180 s) places them with placement.rejoinTarget once they are in game and synced and the round is not ending.
  • roundStart: placement.roundStart moves for everyone present.

server/roundend.lua

The round-end restore, kept dead crew and per-character wallets.

  • CS.restoreAll(): everyone out of borrowed bodies, then everyone back into their home body.
  • Patches on GameSession.EndRound, MultiPlayerCampaign.SavePlayers (prefix and postfix), MultiPlayerCampaign.End, GameSession.GetSessionCrewCharacters, CrewManager.RemoveCharacterInfo, CampaignMode.End (prefix and postfix), GameServer.EndGame, SaveUtil.SaveGame and CrewManager.InitRound.
  • CS.onSavePlayersPre / CS.onSavePlayersPost: hook lists other modules append to; any error in them blocks the save. Because roundend loads before safety, its post-save hook (putting kept crew back) runs before the post-save integrity check.
  • CS.reviveKept(path), R.applyRevive(info, actions), CS.snapshotWallets(), CS.commitWallets().

server/death.lua

  • The kill redirect: prefix and postfix on Character.KillProjSpecific, plus a think safety net that gives the driver back if vanilla threw between them.
  • Undriven permadeath for a home the AI was running.
  • RespawnManager.RespawnCharacters prefix: let go of a dead borrowed corpse before respawn.
  • RetainExperienceForNewCharacter off (SaveExperiencePoints skipped, GetSavedExperiencePoints returns 0).
  • TAKEOVERBOT routing (GameServer.ReadTakeOverBotMessage) and the Client.TryTakeOverBot postfix.
  • CS.onDriverDeath(client, body, disconnected).

server/crewdown.lua

Holds the round open while Free crew exists, using core/grace. A prefix on GameServer.Update sets UsingFreeCam on every client so vanilla’s crew-down test sees “someone is up”; a postfix and a think hook clear it again. Details in Lifecycle.

server/safety.lua

The integrity safety net. See Safety.

  • CS.runCheck(label), CS.noteLegit(kind, infoId), S.stores(campaign), S.holdsLobby().
  • Raises SaveUtil.MaxBackupCount to 20.
  • Patches on Character.KillProjSpecific, CrewManager.FireCharacter, MultiPlayerCampaign.DiscardClientCharacterData, SaveUtil.SaveGame, GameServer.EndGame, MultiPlayerCampaign.LoadCampaign (prefix and postfix) and GameServer.TryStartGame.

server/admin.lua (A)

  • A.load() / A.save(): config.txt in <SaveFolder>/CrewSwap/, written via a .tmp file and a move.
  • A.onChat(text, client) on the chatMessage hook: parses with commands.parse, runs the command, and returns true so the message never shows in public chat (for every sender).
  • A.run(client, cmd): status for everyone; pool, protect, owner and check for admins.
  • Postfix on CampaignMode.TryHireCharacter: in own pool mode the hirer owns the new hire.

client/main.lua (autorun)

Runs on clients only (if not CLIENT then return end) and keeps its state in CSC.

  • CSC.onClick(cm, p): prefix on CrewManager.CharacterClicked. Sends a switch request for a Free row and suppresses vanilla’s click; see Networking.
  • CSC.onRowAdded(cm, p) (postfix on CrewManager.AddCharacterToCrewList), CSC.styleRow(c), CSC.styleAll(): greys taken rows and labels them with the controller’s name, dims crew that are not Free for this player, and restores the original colours otherwise.
  • CSC.rebuildRow(cm, c): rebuilds a row whose IsPlayer flag flipped since it was built (vanilla only builds objective icons for rows that were bots at creation); never mid-drag.
  • CSC.think(): detects the client’s own control change through Game.Client.Character, then CSC.afterOwnControlChange(newChar) does the housekeeping singleplayer’s character selection does and multiplayer skips: close the command UI, clear inventory drag state, HintManager.OnChangeCharacter, refresh an open Talents tab, and reset or auto-hide the crew list and chat box.
  • CSC.applyState(entries), CSC.notify(text, isError), CSC.sendHello().
  • Probe-installs both patches and MakeFieldAccessible(CrewManager, 'crewList'), and looks up seven static classes (Timing, CoroutineManager, ConversationAction, HintManager, TabMenu, GameSession, ChatBox); any failure sets CSC.probesOk = false, which the hello reports to the server.

Lifecycle

This page follows a campaign from server start to save, in the order things happen. Method names are the real patch targets; see Patches for the full list.

Load and boot

ModConfig.xml autoruns server/main.lua on the server and client/main.lua on each client.

On the server, server/main.lua:

  1. Prints [CrewSwap] server loaded build <BUILD>.
  2. Creates the global CS table. After a server Lua reload it carries over what the clients’ hellos said, because the clients’ Lua keeps running and would never resend on its own.
  3. Defines CS.fail and a roundStart hook that repeats the failure notice while one is recorded.
  4. Version gate. Requires CSActive == true (unsandboxed server Lua), registers System.Version and AssemblyInfo, reads Barotrauma.GameMain.Version and AssemblyInfo.GitRevision, prints [CrewSwap] running on game <v>, LuaCs <rev>, and compares them with core/version. On a game-version mismatch (or sandboxed server Lua, or an unreadable game version) it prints [CrewSwap] OFF: version mismatch (...), registers a once-per-round chat notice and returns: no module is loaded and no patch is installed. A different (or unreadable) LuaCs commit only prints [CrewSwap] WARNING: built for LuaCs <expected>, running on <found>; continuing (patch audit and self-tests still guard) and boot carries on.
  5. Defines CS.recomputeActive and a roundStart hook that recomputes it.
  6. Loads the server modules in dependency order. Each module installs its patches and hooks at load time and registers its selftest checks.
  7. Prints modules loaded: ... and patches installed: N/N (36 on the current build), then computes CS.active.
  8. If <SaveFolder>/CrewSwap/selftest exists, runs the in-game selftest one second later.

If the server Lua is reloaded mid-campaign, server/identity.lua notices a loaded campaign it has not seen and re-matches every crew member to its stable id from the mirror file, minting nothing (see Persistence).

On a client, client/main.lua registers its crewswap.state / crewswap.denied handlers first (a server message that arrives before its handler is registered is dropped), probe-installs its two patches, registers the crewswap.hello? handler, and sends its hello two seconds later (all handlers are in place before the first hello goes out). See Networking.

Campaign load

MultiPlayerCampaign.Load runs whenever a campaign is loaded: a fresh load from the lobby, the reload vanilla does after a failed round, or a backup an admin picks.

  • While vanilla builds each CharacterInfo from XML, the postfix on the CharacterInfo(ContentXElement, Identifier) constructor reads crewswap_id and the crewswap_* record fields into CS.pending[info.ID].
  • The MultiPlayerCampaign.Load postfix reads <CrewSwap campaignguid>, builds a fresh registry, binds every live crew info to its pending sid, records duplicate sids in CS.dupes, and fills any gaps from the mirror. It logs identity: load bound N of M crew, campaign <guid>.
  • The MultiPlayerCampaign.LoadCampaign postfix (in safety.lua) takes the integrity baseline and, if the save holds the same sid on two crew members, locks down at once.

Round start and spawning

As vanilla spawns bodies:

  • Character.Create prefix (spawn.lua): for a remote-player spawn with an info in a campaign, p['hasAi'] = true. Every campaign crew body is therefore an AICharacter.
  • CrewManager.InitRound postfix and the character.created hook (roundend.lua): each roster bot’s wallet balance and salary are restored from its record. character.created covers bots created inline (the first round of a campaign) and bench spawns, which never pass through InitRound.

Then the roundStart hooks run:

  • CS.recomputeActive(); the console shows [CrewSwap] round start, active: true|false.
  • On the first campaign round, G.checkLiveList() proves the real crew list converts.
  • The spawn audit: any connected client whose body is not an AI body is a CS.fail.
  • Round-end flags are reset; kept dead crew and staged wallets from the last round are cleared.
  • safety.lua clears the save-block flags, resets the list of legitimate removals and takes the round-start integrity snapshot.
  • Placement (presence.lua): with protect on, everyone stays in their home body. With protect off, each player is moved into the character they last played (the record with their account key in last and the latest lastAt), if it is Free for them.
  • SW.broadcastState() sends every client with client-side LuaCs its view of the crew.

Switch

 client                                   server
 ------                                   ------
 click a Free crew row
 CharacterClicked prefix
   PreventExecution
   guards: dialogue? camera transition?
           3 s cooldown (local)
   crewswap.request(UInt16 entity id)  -->
                                          SW.request
                                            stale/bad client? -> 'inactive'
                                            rules.validate(req, G.view(ch), toggles, ctx)
                                          SW.switch(client, target)
                                            SetClientCharacter(client, nil)
                                            old:ClearInputs(); old.AIController:Reset()
                                            SetClientCharacter(client, target)
                                            record.last / lastAt; own-pool claim
                                            Permadeath: adopt as new home?
                                          SW.broadcastState()
                                      <-- crewswap.state (to every client that said hello)
 think: Game.Client.Character changed
   housekeeping, "Now playing <name>",
   restyle rows

Points worth knowing:

  • Two SetClientCharacter calls. Vanilla sends ControlEventData(null) for the body being left only when a client is set to nil; a direct body-to-body switch skips it and leaves a stale “is a player” flag on other clients (breaking crew-list markers and the death prompt’s bot list). Releasing first and then binding avoids that.
  • ClearInputs() and AIController:Reset() on the old body make it pause briefly and then pick up its orders again, the way singleplayer does.
  • Never TryTakeOverBot, never RenamingEnabled. Vanilla’s takeover discards the client’s CCD, moves salary and allows a free rename.
  • Validation is entirely server-side. The client’s guards only spare a round trip. rules.validate checks, in order: the mod is active, a round is running and not ending, the requester is in game and synced, they have a campaign character (nohome otherwise), they are not a voluntary spectator, not a traitor, not in a conversation, and off cooldown; and that the target is crew, has no controller, is alive, conscious, not removed, an AI body, and allowed by the toggles.
  • First come, first served. Requests are handled one at a time on the server’s main thread; the second request for the same body finds a controller and is denied with taken.
  • A permadeath-set spectator is un-spectated. A Permadeath kill sets SpectateOnly on the driver in vanilla, and vanilla’s ownership loop would kill the new body at once. SW.switch clears it, but a player who chose to spectate in the lobby cannot switch in.
  • No home yet, no switch. A first-time joiner has no CCD until they spawn once. SetClientCharacter points client.CharacterInfo at the target, and only a CCD ever points it back, so switching now would lose their character. Both rules.validate and SW.switch refuse.
  • Own-pool claim. In own mode, the first player to take a character with no owner (and that is nobody’s home) becomes its owner.
  • Permadeath adoption. If the switching player’s home is permanently dead and the target is nobody’s home, adoptHome makes it their new home: SetClientCharacterData builds a CCD from the target’s info, the info is removed from the CrewManager roster (so it lives in exactly one store), and the integrity check is told this move is legitimate (adopted).

On the client, CSC.think() notices that Game.Client.Character changed. It records the new id before any side effect (so a throwing UI call cannot re-fire every frame), sets the local cooldown, runs the housekeeping listed in Modules, shows [CrewSwap] Now playing <name> and restyles the rows.

Death

Vanilla’s Character.KillProjSpecific finds “the player who died” by looking for the client whose c.Character is the dying body, and applies every death effect to that client’s CCD: ApplyPermadeath, SpectateOnly = true, SaveExperiencePoints for RetainExperience, the Ironman disk write and the traitor message. After a switch, that is the wrong character.

CrewSwap redirects it:

  1. Prefix (crewswap.kill.pre): if the body is crew and driven, remember { driver, body, cause, balance } in CS.deathCtx[ch.ID] and set driver.Character = nil, which hides the driver from all three of vanilla’s lookups.
  2. Vanilla kills the body with nobody to blame.
  3. Postfix (crewswap.kill.post): give the driver their body back (they stay attached to the corpse until they switch or respawn) and apply the effects to the character that died:
    • re-point driver.CharacterInfo at their home info with a raw assignment, so vanilla’s mid-round respawn spawns their home and not a second copy of the borrowed character;
    • in Permadeath, unless the death was a disconnect: if the dead info has a CCD (it is someone’s home), call ApplyPermadeath on that CCD and pay the body’s wallet into the campaign bank, because ApplyPermadeath drops the CCD wallet; a bot just gets PermanentlyDead = true; then bump the CharacterInfo net flag;
    • send the traitor notice to the driver if a traitor did it;
    • broadcast state so the dead player sees which crew are Free.
  4. A think hook restores the driver if vanilla threw inside KillProjSpecific and the postfix never ran.

Related pieces:

  • Undriven permadeath. Vanilla only makes a death permanent through a driving client. When the AI was running a player’s home (its owner was playing someone else), the postfix applies permadeath itself (again skipped when the cause of death is a disconnect), so “the character who died is permanently dead” holds for everyone.
  • Respawn keeps the corpse. RespawnManager.RespawnCharacters despawns each client’s current body. Outside Permadeath, a prefix first detaches a client from a dead borrowed body, so the corpse stays in the world (with its items) instead of being despawned.
  • The vanilla takeover request. The Permadeath death prompt offers “take over a bot”, which sends TAKEOVERBOT. A prefix on GameServer.ReadTakeOverBotMessage peeks the info id. For a crew member already in the world it becomes a normal CrewSwap switch request. For a hire or bench id, vanilla runs only in Permadeath and only when the player has no CCD or their home is permanently dead (and with client.CharacterInfo pointed back at the home first); otherwise it is refused. If the routing itself errors, vanilla is still prevented, because failing open here could discard a living home. A postfix on Client.TryTakeOverBot records the result as the player’s new home.
  • RetainExperienceForNewCharacter is off while active: the prefix on MultiPlayerCampaign.SaveExperiencePoints skips it and GetSavedExperiencePoints returns 0.

Crew-down grace

Vanilla’s GameServer.Update ends the round when every connected client is !UsingFreeCam and (no character, dead, or incapacitated). With switching, that is too early: there may be Free crew to jump into.

server/crewdown.lua reproduces vanilla’s test term for term (vanillaWouldBeDown). When vanilla would end the round and at least one in-game client could take a Free crew member, the core/grace timer runs for up to 120 s with three chat lines (start, 30 seconds left, nobody switched in). While it is open, the GameServer.Update prefix sets UsingFreeCam = true on every client for the duration of that one Update call, so vanilla sees “someone is up” and resets its own timers. The postfix clears the flag again, and a think hook clears it too in case Update threw and Harmony skipped the postfix.

Masking the input of vanilla’s check, rather than reimplementing its several crew-down branches, keeps every branch (Permadeath, AutoRestart, the campaign’s 2 s / 120 s timers) unchanged. When the grace ends without a switch, the round ends exactly as vanilla would have. A real admin free-cam is respected, because a client that was already in free-cam counts as “up” in vanilla too.

Disconnect and rejoin

  • Disconnect. LuaCs’s client.disconnected fires as a prefix on vanilla’s disconnect, while client.Character is still set. CrewSwap records the client as the body’s last controller and releases it. Because the client no longer owns a body, vanilla never marks it disconnected, so the body reverts to a working bot instead of freezing and being killed after KillDisconnectedTime. It also stops “disconnect to dodge death”.
  • Rejoin. A postfix on GameServer.OnInitializationComplete queues the client (LuaCs’s client.connected reports the last connected client, which can be the wrong one when two join together). A think poll waits until the client is in game, synced and the round is not ending, then applies placement.rejoinTarget: the character they last played if protect is off and it is Free, otherwise their own home body if it is Free for them.
  • New players spawn through vanilla. Until they have spawned once they have no CCD, and switch requests are refused with nohome.

Round end and save

On a level transition, GameSession.EndRound runs mission pay, round-end talents and GameMode.End (MultiPlayerCampaign.End sets GameOver, then calls CampaignMode.End). After it returns, the transition calls MultiPlayerCampaign.SavePlayers and then SaveUtil.SaveGame. On save-and-quit the order is reversed: SavePlayers and SaveGame run first, and GameServer.EndGame then runs EndRound / End (see the save-and-quit path). CrewSwap hooks each step; the table follows the level-transition order, which is the one the crewswap.endround and crewswap.endguard rows describe:

StepPatchWhat CrewSwap does
GameSession.EndRoundprefix crewswap.endroundCS.roundEnding = true (no switches or rejoin moves from now on); snapshot each client’s home body, because CampaignMode.End despawns dead bodies before SavePlayers.
Mission pay, talentspostfix crewswap.crewset on GameSession.GetSessionCrewCharactersAdd released home bodies (alive bots with a CCD) to the crew set wherever bots are included, so an idle home gets mission XP and round-end talents like any bot. Pay itself goes to whoever each player is driving.
CampaignMode.Endprefix/postfix crewswap.endguardRaise the keep-dead guard and stage a snapshot of every bot wallet.
(inside End)prefix crewswap.keepdead on CrewManager.RemoveCharacterInfoWhile the guard is up, skip vanilla’s removal of a dead non-home crew member and record it in CS.keptDead.
MultiPlayerCampaign.Endpostfix crewswap.gameoverCorrect GameOver on save-and-quit (below).
SavePlayersprefix crewswap.restoreRestore (below), then the pre-save hooks: keep-dead decisions for the bodies SavePlayers will drop, and the pre-save integrity check.
SavePlayerspostfix crewswap.restore.postPut kept crew back in the roster, revive them, run the post-save integrity check.
MultiPlayerCampaign.Saveprefix/postfixMint ids for new crew, write <CrewSwap>, write the mirror.
CharacterInfo.SavepostfixWrite crewswap_* attributes on each <Character>.
SaveUtil.SaveGameprefixesCommit staged wallets into records; block the write if a check failed.
GameServer.EndGamepostfixesRevive kept crew on an unsaved end; reload the last good save if a write was blocked.

The restore

CS.restoreAll() runs in the SavePlayers prefix:

  1. Everyone leaves any body that is not their home. A live borrowed body is released with SW.release (it must not still be a client’s character, because SavePlayers snapshots items only for bots and then deletes every inventory); a dead or removed one is just let go.
  2. A living home goes back to its owner with SetClientCharacter (releasing whoever was driving it first).
  3. A dead home is bound raw (c.Character = h; c.CharacterInfo = h.Info), never left nil, because vanilla builds a dead player’s CCD item data from client.Character.
  4. A client with no home body (never spawned, or benched) keeps no character.

If the restore throws, CS.blockSave is set and the save will not be written.

Why the restore is in SavePlayers and not at round end

The restore must run after mission pay and round-end talent effects (so they reach the body each player was actually driving), must cover the save-and-quit path (which calls SavePlayers without a normal round end), and must be a no-op on a failed-round reload (LuaCs’s roundEnd hook also fires as a prefix on GameSession.LoadPreviousSave). A prefix on MultiPlayerCampaign.SavePlayers meets all three.

Kept dead crew and revival

Outside Permadeath, a dead non-home crew member is kept rather than deleted (core/revive.plan decides). There are two removal sites:

  • CampaignMode.End’s dead-body loop calls CrewManager.RemoveCharacterInfo, which the crewswap.keepdead prefix can skip.
  • SavePlayers removes dead bots too, but no patch on RemoveCharacterInfo fires for that call site at runtime on a dedicated server (the tiny method is inlined into SavePlayers’ patched replacement). So the SavePlayers prefix decides up front, using revive.dropsAtSave, which bodies vanilla will drop, and the postfix puts any kept info vanilla removed back with CrewManager.AddCharacterInfo.

Each kept info is then revived with R.applyRevive: the vanilla skill penalty (ApplyDeathEffects) when the round saves and the death was not a disconnect, otherwise just clearing the cause of death; health data cleared; orders cleared; the dead Character reference dropped; and PendingSpawnToActiveService = true so they spawn alive next round. A revive failure blocks the save, since CrewManager.SaveMultiplayer would otherwise drop the unrevived info.

The save-and-quit path

Save-and-quit differs from a level transition in three ways the code handles explicitly:

  • Order. SavePlayers runs before MultiPlayerCampaign.End, while GameSession.IsRunning is still true. End sets GameOver from “no in-game client has a living character”; after the restore, a player whose home is dead or benched has no living character, which would skew it. The restore records CS.restoredBeforeEnd and whether anyone was alive before restoring, and the MultiPlayerCampaign.End postfix puts back that pre-restore value. It never recomputes GameOver from a Free-crew rule.
  • Pay. Mission pay is distributed while the round is closed down, after the restore, so on save-and-quit the wallets that receive it are the home bodies’. This is a deliberate trade-off; deferring pay across the save was judged not worth the save-critical complexity.
  • Corpse inventory. On a level transition, CampaignMode.End despawns dead bodies first, so a kept bot’s items are already in a duffel bag in the world and its saved inventory is cleared on revival. On save-and-quit, vanilla saves a dead character’s inventory from the corpse and deletes the world items, so no duffel exists. A bot revived after save-and-quit therefore keeps the inventory vanilla saved from its corpse, matching what vanilla does for a dead player’s character on that path. Nothing is duplicated either way.

Unsaved round ends

A vote to end, a crew-down failure or an admin end without saving never reaches SavePlayers. The GameServer.EndGame postfix revives kept crew on the unsaved path (no skill penalty, since vanilla only penalizes deaths when the round saves) unless a save was just blocked (the campaign is about to be reloaded anyway), then drops the staged wallets, so bot wallets revert together with the player characters’ CCD wallets.

After the round

The next roundStart resets CS.roundEnding, the kept-dead list, staged wallets and the save-block flags, and the cycle starts again. On the client, the roundEnd hook forgets the crew-list rows (they are rebuilt before the next roundStart fires) and the control tracker.

Persistence

CrewSwap’s rule for storage is simple: vanilla’s save is the source of truth, and CrewSwap’s data rides inside it. Mod state saves, reverts and backs up exactly when the campaign does, and a save is always loadable without the mod.

What vanilla stores (and CrewSwap leaves alone)

DataWhere vanilla keeps it
A player’s home characterThat client’s CharacterCampaignData (CCD), keyed by account id (falling back to address). The CCDs are written to <campaign>_CharacterData.xml next to the .save. Each CCD carries the character’s info, inventory, health, orders and wallet.
Every other crew memberThe CrewManager roster (active crew plus the reserve bench), saved inside the campaign .save.
Rolling backupsVanilla’s own numbered backups of the save, managed by SaveUtil. CrewSwap raises SaveUtil.MaxBackupCount to 20.

CrewSwap never moves a character between these stores during play. The only moves it makes are ones vanilla itself would make, and each is reported to the integrity check:

  • Permadeath adoption: a player whose home is permanently dead takes a crew member who is nobody’s home. adoptHome (in server/switch.lua) creates their new CCD from that info with MultiPlayerCampaign.SetClientCharacterData and removes the info from the roster.
  • Vanilla’s own TryTakeOverBot (only reached in Permadeath, when the player has no home yet or their home is permanently dead), which does the same thing.

Stable ids

CharacterInfo.ID is reassigned every time a save is loaded, so it is only a per-session handle. Anything CrewSwap persists about a character needs an identity that survives save, load, restarts and renames. CrewSwap gives every crew CharacterInfo a random 32-hex-digit GUID, called a sid in the code (CS.newGuid() uses System.Guid.NewGuid():ToString('N'), falling back to math.random if System.Guid is unavailable).

The in-memory registry (core/registry.lua, held in CS.reg) has two maps:

  • bySession: CharacterInfo.ID → sid, rebuilt after every load.
  • chars: sid → record, where a record is:
FieldXML attributeMeaning
lastcrewswap_lastAccount key of the last player who drove this character.
lastAtcrewswap_lastatos.time() of that switch, in seconds. A player’s “last played” character is the record with their key in last and the greatest lastAt.
ownercrewswap_ownerAccount key of the owner in own pool mode (the hirer, the first player to take an unclaimed character, or whoever an admin assigned).
homecrewswap_homeAccount key of the player whose home this became through a takeover or Permadeath adoption. Informational only (kept for diagnostics); the live home is always taken from the CCD.
wbalcrewswap_wbalBot wallet balance.
wrdcrewswap_wrdBot salary (reward distribution).

An account key is AccountId.StringRepresentation, or addr:<address> for a client without an account id, which is how vanilla keys CCDs too. Vanilla saves already contain these account ids in the CCD file; CrewSwap adds no new kind of personal data. Stable ids never leave the server; the network protocol uses entity ids only.

CS.sidOfInfo(info) is the one way to get a sid. It returns the existing binding, or adopts the sid just read from XML for that info (CS.pending), or mints a new GUID (counted in CS.minted). CS.sidOfInfo itself mints unconditionally; its callers decide. The save path (bindMissing in the MultiPlayerCampaign.Save prefix) and G.view mint only while CS.active, so a campaign played while the mod is inactive stays vanilla. The exceptions are the integrity stores used by the load-time snapshot and by a manual !crewswap check: both mint an id for any unbound crew info even during a lockdown. The load-time snapshot does so only in a non-Ironman campaign on a matching version. The manual check (and the safety-runcheck selftest) can mint in-memory ids in any multiplayer campaign, Ironman included; those ids are only ever saved where CS.campaignOk holds, so an Ironman save stays vanilla.

Where CrewSwap data lives in the save

crewswap_* attributes on <Character>

A postfix on Barotrauma.CharacterInfo.Save(XElement) adds crewswap_id plus the record’s attributes to the <Character> element vanilla just built. Because vanilla uses the same method for both CCD infos and roster bots, every crew member carries its sid and record wherever vanilla puts it: in <campaign>_CharacterData.xml for homes and inside the .save for bots.

<Character name="Kim Lee" ... crewswap_id="3f2a...9c" crewswap_last="..." crewswap_lastat="1700000000"
           crewswap_wbal="500" crewswap_wrd="25" />

A postfix on the CharacterInfo(ContentXElement infoElement, Identifier npcIdentifier) constructor reads them back into CS.pending[info.ID]. It is not gated on CS.active, because it runs during campaign load, before the mod knows whether the campaign is one it handles.

<CrewSwap> in <MultiPlayerCampaign>

The postfix on MultiPlayerCampaign.Save(XElement, Boolean) adds one element:

<MultiPlayerCampaign ...>
  ...
  <CrewSwap campaignguid="<32 hex>" modversion="0.9.0" />
</MultiPlayerCampaign>

campaignguid identifies the campaign for the mirror file (below). modversion records which release last wrote the save. The MultiPlayerCampaign.Load postfix reads it back.

When ids are written

  • The MultiPlayerCampaign.Save prefix recomputes CS.active. If the campaign object is one it never saw load (CampaignID differs), it is a new campaign: a fresh registry and a new campaign GUID. It then binds every live crew info (bindMissing), minting ids for new hires and homes only while active, logs identity: save: minted N new ids for M crew, and prunes stale session bindings. After a restart or reload, existing crew must show minted 0.
  • During a lockdown (see Safety) every id and record the mod already holds is still written, because CS.campaignOk ignores lockdowns. Only minting waits for CS.active. Otherwise the next vanilla save during a lockdown would drop the mod’s data for good.

The mirror file

After every real save (not the save-on-load), the MultiPlayerCampaign.Save postfix writes a recovery mirror next to the save:

Multiplayer/
  MyCampaign.save                  vanilla
  MyCampaign_CharacterData.xml     vanilla (CCDs)
  MyCampaign_CrewSwap.xml          CrewSwap mirror
<CrewSwapMirror campaignguid="<32 hex>">
  <c id="<sid>" on="<original name>" h="<identifier hash>" job="<job id>"
     crewswap_last="..." crewswap_wbal="500" ... />
  ...
</CrewSwapMirror>

It is written to <path>.tmp first and then moved into place, so a crash never leaves half a file.

The mirror is never the source of truth. It exists for two situations where the save attributes are not enough:

Recovery after a Lua reload or a mod-off gap

  • Lua reload mid-campaign. A reload empties the registry while the infos stay loaded in memory (their XML was read long ago). At load, server/identity.lua sees a campaign object it has not seen and calls bindMissing(infos, false): it re-matches the live infos to mirror rows with core/mirror.match and logs identity: re-matched N of M from the mirror. Nothing is minted.
  • A save written while the mod was off. A game-version mismatch or a sandboxed server means vanilla wrote the save without any crewswap_* attributes. The mirror still holds each character’s sid and record, so they are restored when the mod comes back.

mirror.match pairs rows to live infos first by CharacterInfo.GetIdentifierUsingOriginalName() (a hash vanilla derives from the original name, the head preset, the hair, beard, moustache and face-attachment choices, and the job; it changes if any of those change) and then, for whatever is left, by the original name alone. A pair is made only where exactly one row and one live info share the key. Ambiguous cases stay unmatched and get new ids later. The job is stored in the row but is not a match key.

A save with no <CrewSwap> element adopts the mirror’s campaign GUID only if the mirror re-matches at least half of the unbound crew. That guards against a stale mirror left behind by a deleted campaign that had the same name.

Per-character wallets

Vanilla persists a wallet only through a CCD. A bot’s wallet lives on its body and is lost at save. Because money is per character in CrewSwap, every roster bot’s wallet is stored on its record:

  1. Stage. In the CampaignMode.End prefix, CS.snapshotWallets() copies each bot’s Wallet.Balance and Wallet.RewardDistribution into CS.walletStage, keyed by sid. If any body cannot be read, the save is blocked rather than written with a stale wallet.
  2. Commit. In the SaveUtil.SaveGame prefix of a save that is really being written (not the save-on-load, not a blocked save), CS.commitWallets() re-snapshots the live wallets and moves the stage into the records. A failure there also blocks the write. The re-snapshot is needed because on save-and-quit the save is written before CampaignMode.End runs, so there is no stage yet: the commit’s own re-snapshot is what writes the wallets, and the stage taken afterwards at End is dropped by the GameServer.EndGame postfix.
  3. Discard. On an unsaved end, the GameServer.EndGame postfix drops the stage.
  4. Restore. In the CrewManager.InitRound postfix and on character.created, each roster bot with a stored wbal gets Balance set, ForceUpdate() called, and SetRewardDistribution(wrd).

Design decision: stage, then commit. Committing at CampaignMode.End would let an unsaved end (which continues from memory) keep bot earnings while player characters’ CCD wallets revert to the last save. Staging keeps money-per-character consistent: everything reverts together.

Home wallets stay vanilla’s (in the CCD). When a home dies for good in Permadeath, vanilla’s ApplyPermadeath drops the CCD wallet; CrewSwap pays that balance into the campaign bank first (homePermadeath in server/death.lua).

The config file

Server-wide settings, not per campaign:

<SaveFolder>/CrewSwap/config.txt

<SaveFolder> is the game’s save folder (Game.SaveFolder), the folder that contains Multiplayer/. The format is two key=value lines:

pool=shared
protect=on

Missing file, unreadable file, unknown keys and bad values all fall back to the defaults (pool=shared, protect=on). The file is rewritten (via .tmp and a move) whenever an admin changes a setting. If writing fails, the setting still applies until the server restarts and the admin is told.

The same folder holds two optional marker files: selftest (run the in-game selftest at load; see Testing) and force-integrity-fail (development only: makes every integrity check fail; see Safety). Nothing is ever written inside the mod’s own folder, because that folder ships to every client.

Per-campaign vs per-session state

StateLifetime
sids, records (owner, last played, wallets)Per campaign, in the save.
Campaign GUIDPer campaign, in the save.
pool, protectServer-wide, config.txt.
Who is driving whomPer round, in memory only. The restore puts everyone home before every save.
Kept dead, staged wallets, integrity snapshotIn memory, reset by round start or load.
Hello state (client builds, join times)In memory, reset when that client disconnects. Client builds, bad flags, join times and the no-LuaCs-notice flag are carried over a server Lua reload; the hello-ask counter is not.
Switch cooldownsIn memory, time-based: they expire after 3 s (or are reset by a server Lua reload).

Safety

CrewSwap touches save-critical code paths: who is stored where, which dead are kept, whose wallet holds what. This page describes the layers that stop a bug from reaching the disk, and how an admin recovers if something does go wrong. The admin-facing version is in Troubleshooting.

The layers, from the outside in:

  1. Version gate: wrong game version (or sandboxed server Lua), no patches at all; a different LuaCs commit only logs a warning.
  2. Patch-install audit: any patch that fails to resolve switches the mod off.
  3. Runtime audits: wrong-shaped bodies or an unreadable crew list switch the mod off.
  4. Every callback is guarded: errors are caught, logged and, on save paths, block the save.
  5. Integrity check: storage is verified before and after vanilla’s SavePlayers; a failure blocks the write, reloads the last good save and locks the mod down.
  6. Backups: vanilla keeps 20 rolling backups instead of 3.

1. Version gate

Harmony patches on internal methods break when the game or LuaCs changes. CrewSwap is built and tested against one game version and one LuaCs commit, recorded in shared/build.lua. Only the game version is enforced; the LuaCs commit is advisory:

EXPECT_GAME = '1.13.4',     -- any 1.13.4.x revision
EXPECT_LUACS = '<commit>',  -- short git hash; the first 7 characters are compared

At load, server/main.lua:

  • requires CSActive == true. Reading the versions needs unsandboxed server Lua; a sandboxed server counts as a mismatch;
  • reads Barotrauma.GameMain.Version (after registering System.Version) and AssemblyInfo.GitRevision (the LuaCs commit, the same value LuaCs logs at start-up);
  • compares them with core/version.compare.

On a game-version mismatch the console shows [CrewSwap] OFF: version mismatch (game <v>) (or (server Lua is sandboxed), or (version read failed: ...)), and players get one chat line per round: [CrewSwap] CrewSwap is off until it's updated for this game version (...). No patch is installed and no module is loaded, so the game runs vanilla code, saves are written by vanilla, and CrewSwap’s data in the save is simply ignored (and kept by the mirror; see Persistence).

A different LuaCs commit, or one that can’t be read (empty or missing GitRevision), does not turn the mod off. The console shows

[CrewSwap] WARNING: built for LuaCs <expected>, running on <found>; continuing (patch audit and self-tests still guard)

and boot carries on. LuaCs updates itself through the Workshop far more often than the game does, and most of its updates don’t touch anything CrewSwap patches. The layers below are what catch a LuaCs change that does matter: every patch must install (section 2) and the start-up and runtime checks must pass (the live crew-list check on the first campaign round, the AI-body audits, the required game API checks), or CS.fail turns the mod off. The opt-in in-game selftest only prints SELFTEST lines; it never calls CS.fail. So “self-tests” in the warning line means those always-on checks (such as SELFTEST PASS/FAIL live-crew-list), not the opt-in selftest.

Clients do not run their own version gate. The game handshake already refuses a client whose game version differs from the server’s, and the LuaCs commit is not readable from sandboxed client Lua. Instead the client feature-probes its patches and reports the result together with its BUILD in its hello (see Networking).

Design decision. A server pinned to an old game version would stop players whose clients auto-update from joining. So the server updates with everyone, and on a new game version the mod turns itself off until a release for that version is made. The cost is that, while off, vanilla rules apply (for example a dead bot is lost). A LuaCs update gets a warning instead of the same treatment: switching the mod off for every LuaCs commit would break it every time LuaCs updates, while a LuaCs change that does break a patch is already caught by the patch-install audit.

2. Patch-install audit

Every server Hook.Patch goes through P.patch in server/patch.lua, which wraps it in pcall. A patch that does not resolve (a renamed method or changed signature, typically from a game hotfix, since the gate accepts any 1.13.4.x revision) calls CS.fail('patch <id> (<class>.<method>): ...'). The console prints patches installed: N/N at load; anything less than all of them means the mod is off.

Likewise a module that throws while loading, MakePropertyAccessible(MultiPlayerCampaign, 'GameOver') failing, ImmutableHashSet`1 not registering or SaveUtil.MaxBackupCount not being settable all call CS.fail.

3. Runtime audits

  • AI bodies. At round start and on character.created, any player body in a multiplayer campaign that is not an AICharacter with a HumanAIController is a CS.fail. The hasAi prefix on Character.Create could fail open (a Lua error in a prefix lets the original run), so the result is checked rather than trusted.
  • Live crew list. On the first campaign round, G.checkLiveList() converts CrewManager:GetCharacterInfos(true) and checks every element is a CharacterInfo. The selftests run before any campaign is loaded, so this is the first proof that the real list converts.

What CS.fail means

CS.fail(reason) sets CS.active = false and CS.lockdown = true, prints [CrewSwap] FAIL: <reason> and announces [CrewSwap] CrewSwap is off until an admin restarts the server (see the server console). now and at every round start. !crewswap status shows CrewSwap is off: <reason>.

The campaign stays safe: switching stops, and the identity layer keeps writing every id and record it already holds (new ids are minted only by the load-time integrity snapshot and a manual check; see Persistence). !crewswap check does not lift a failure; it only lifts integrity lockdowns. A broken build must stay off until it is fixed and the server restarted.

An integrity lock can be raised after a failure (a load-time duplicate or a revert while CS.failReason is already set), which marks the lockdown as an integrity lock too. A passing !crewswap check then lifts the lockdown only when CS.failReason == nil; with a failure on record it clears CS.integrityLock alone, leaves CS.lockdown set and replies that CrewSwap is still locked down. A load during that lock still lifts the lockdown for the duration of the load (so the save-on-load carries the ids) and puts it back in the postfix. !crewswap status keeps showing the failure reason.

4. Guarded callbacks

Most callbacks are wrapped in pcall (the modules’ safe(...) and guard(...) helpers, or an inline pcall). The few trivial flag-setting prefixes (crewswap.retainxp.save, crewswap.safety.save, crewswap.safety.load.pre) are unwrapped, and crewswap.retainxp.get and crewswap.safety.lobby return values deliberately. Errors are logged with the module name (for example [CrewSwap] roundend: restore error: ...; the exact prefix varies by module). The round-end, death, safety and admin modules also count them in an errors table (the crew-set-postfix selftest reads roundend’s); the *-patches selftests read P.errors, the patch-install failures. On save-critical paths an error sets CS.blockSave:

  • the SavePlayers restore;
  • any onSavePlayersPre / onSavePlayersPost hook (keep-dead decisions, revival, integrity checks);
  • a bot wallet that cannot be read at CampaignMode.End or at the save itself.

Where failing open would be dangerous, the code fails closed instead. The TAKEOVERBOT prefix, for example, prevents vanilla from running if its own routing throws, because vanilla’s takeover can discard a living home.

5. Integrity check

What is checked

core/integrity.lua works on plain lists of sids, produced by S.stores(campaign) in server/safety.lua:

  • ccd: every CCD’s info (MultiPlayerCampaign.characterData, made accessible with MakeFieldAccessible);
  • roster: every CrewManager info, active crew plus bench;
  • table: every sid the registry holds a record for.

integrity.check(snapshot, now, legit) compares the stores now with a snapshot taken at round start and after every campaign load, and reports:

ProblemMeaning
duplicate <sid>The same character is in more than one place (two CCDs, a CCD and the roster, or twice in one).
missing <sid>A character present at the snapshot has vanished.
moved <sid>A character moved between CCD and roster (a bot stored as a player character, or the reverse).
unknown <sid>The registry has a record for a character that exists in neither store.

The snapshot also records stale sids: records whose character was already gone at the snapshot (fired or permanently dead in an earlier round). Records are kept for the session, so stale sids are not reported as unknown forever after.

Legitimate changes

Vanilla (and the mod) remove or move characters on purpose. Those are noted by CS.noteLegit(kind, infoId) during the round:

KindNoted by
permadeadA crew death in Permadeath (postfix on Character.KillProjSpecific), or a home already permanently dead at the snapshot (it keeps its CCD until replaced).
firedPrefix on CrewManager.FireCharacter.
discardedPrefix on MultiPlayerCampaign.DiscardClientCharacterData (vanilla “create new character”, and the CCD drop inside TryTakeOverBot).
adoptedPermadeath adoption of a new home in SW.switch, and the Permadeath takeover fall-through. Allows exactly one roster-to-CCD move, including the moment vanilla briefly has it in both.

When it runs

CS.runCheck(label) runs:

  • pre-save: after the restore, in the SavePlayers prefix (only while CS.active, so it is skipped during a lockdown even though the restore still runs);
  • post-save: after vanilla’s SavePlayers (and after kept crew are put back), also only while CS.active;
  • manual: when an admin types !crewswap check;
  • selftest: the in-game selftest’s safety-runcheck check.

Each problem is printed as [CrewSwap] INTEGRITY FAIL (<label>): <problem> <sid> (<name>), and chat gets a one-line summary: [CrewSwap] Integrity check failed (<label>): <N> problem(s), see the server console.

What a failure does

 SavePlayers prefix/postfix   check fails -> CS.blockSave = true
 SaveUtil.SaveGame prefix     blockSave -> PreventExecution (nothing is written)
                              CS.saveBlocked = true
 GameServer.EndGame postfix   saveBlocked ->
                                lock(ALARM)          lockdown + integrityLock
                                MultiPlayerCampaign.LoadCampaign(DataPath, nil)
                                IncrementAllLastUpdateIds()
 TryStartGame prefix          while locked -> refuse, return GameAlreadyStarted
  1. Don’t write. The SaveUtil.SaveGame prefix sets PreventExecution, so nothing reaches the disk. The save-on-load (isSavingOnLoading) is never blocked: it only rewrites what was just loaded.
  2. Reload. Memory already holds what SavePlayers did (folded CCDs, deleted inventories), so continuing from memory is not an option. The GameServer.EndGame postfix reloads the last good save the way vanilla’s failed-round branch does, then bumps every net update id so clients resync.
  3. Lock before reloading. On the server, LoadCampaign starts the next round itself before it returns. The lock is therefore set before the reload, or it would never reach the lobby refusal.
  4. Hold the lobby. While an integrity lock is in force, a prefix on GameServer.TryStartGame refuses to start a round (it returns TryStartGameResult.GameAlreadyStarted and announces The round can't start: an integrity check failed. An admin must run !crewswap check.). Switching is off too (CS.active is false).
  5. Load-time duplicates. If a save being loaded already holds one sid on two crew members (CS.dupes, found by the MultiPlayerCampaign.Load postfix), the LoadCampaign postfix locks down at once with its own alarm, and TryStartGame is refused even inside that load.

Lifting a lockdown

!crewswap check runs the check against the current state. If it passes and the lockdown came from an integrity failure (CS.integrityLock) and no failure is on record (CS.failReason == nil), the lock is lifted and CS.active recomputed. If the mod is also off for another reason (a failed patch or another CS.fail), only CS.integrityLock is cleared, the lockdown stays, and the reply says it is still locked down. Because S.holdsLobby() needs CS.integrityLock, the lobby hold is released, so rounds can start again, but CrewSwap stays off (switching disabled) until the FAIL is fixed and the server restarted. On a game-version mismatch no !crewswap command is handled at all, because no module is loaded.

A load during a lockdown (an admin loading a backup from the lobby) re-saves straight away. The LoadCampaign prefix lifts the integrity lock for the duration of the load so that save-on-load carries the ids, and the postfix puts it back. The admin then runs !crewswap check against the loaded backup.

Ordering between modules

roundend.lua and safety.lua both have prefixes on SaveUtil.SaveGame and postfixes on GameServer.EndGame, and Harmony does not promise an order between them. The code is written to be order-independent: the wallet prefix blocks the write itself if its commit fails, and CS.lastSaveBlocked (set by safety’s SaveGame prefix, cleared only at round start) survives safety’s EndGame postfix clearing CS.saveBlocked, so roundend’s EndGame postfix can tell that the save was blocked whichever runs first.

Testing the alarm

The development-only marker <SaveFolder>/CrewSwap/force-integrity-fail makes every check fail with forced by /CrewSwap/force-integrity-fail. Use it on a throwaway campaign to see the whole block-reload-lock-check sequence, then delete it.

6. Backups

At load, server/safety.lua sets SaveUtil.MaxBackupCount = 20 (vanilla keeps 3). These are vanilla’s own rolling backups, so they appear in the lobby’s load list for the campaign, and an admin can load one when the check keeps failing.

Design decision. Copying save files to a separate dated backup folder at every round start was considered. Raising vanilla’s count gives the same protection with no new file handling and a restore path admins already know.

Recovery summary

SymptomCauseWhat lifts it
OFF: version mismatchThe game version differs from the build, server Lua is sandboxed, or the game version couldn’t be readA CrewSwap release for that version, or LuaCs’s default C# run policy.
WARNING: built for LuaCs ...The LuaCs commit differs from the one testedNothing needed; the mod keeps running. Put back the tested LuaCs build if something misbehaves.
FAIL: <reason>Patch, module, audit or live-list failureFix the cause, restart the server (!crewswap check can’t lift it).
Integrity check failed: the round was not saved ...A check failed at save time!crewswap check; if it keeps failing, load a backup, then !crewswap check.
... this save has the same crew member twice ...The loaded save has a duplicate idLoad an older backup, then !crewswap check.

Patches and LuaCs gotchas

CrewSwap changes vanilla behaviour only through Hook.Patch (Harmony prefixes and postfixes driven from Lua) and Hook.Add (LuaCs events). This page lists every patch, why it exists, and the LuaCs behaviours that shaped how they are written.

Every server patch is installed through P.patch(id, class, method, types, fn, when) in server/patch.lua; the id is the first column below. “Before” is a prefix, “After” a postfix. All 36 server patches are installed at load. Patches are never removed, so every callback is gated on mod state: most on CS.active; the identity layer on CS.campaignOk (and the XML constructor / MultiPlayerCampaign.Load postfixes not at all, because they run before activity is known); the round-end restore on CS.active or CS.lockdown, so a lockdown save still gets the vanilla shape; and the safety/save-blocking patches on their own block and lock flags, so they still work during a lockdown. A few others gate on the state they handle (the kill postfix on a pending death context, the game-over postfix on a restore that already ran). The end-round prefix (crewswap.endround) sets CS.roundEnding ungated and uses the restore’s CS.active or CS.lockdown gate only for the home snapshot. The wallet commit (crewswap.wallets.save) is gated on the save’s own flags (isSavingOnLoading, CS.blockSave), with activity checked inside the wallet snapshot. Don’t “fix” these gates to CS.active: the differences are what keep saves safe. Cleanup callbacks that only lower flags or clear per-round state (crewswap.endguard.post, crewswap.endgame, crewswap.crewdown.post, and the safety revert/load postfixes) are deliberately ungated, so state never sticks after a lockdown.

Server patches

Bodies and identity

IdTargetWhenWhy
crewswap.forceaiBarotrauma.Character.Create (the 10-parameter overload: CharacterPrefab, Vector2, String, CharacterInfo, UInt16, Boolean, Boolean, Boolean, RagdollParams, Boolean)BeforeSet hasAi = true for campaign remote-player spawns, so every crew body is an AICharacter that can be handed back to the AI.
crewswap.ci.saveBarotrauma.CharacterInfo.Save(XElement)AfterAdd crewswap_id and the record’s attributes to the <Character> element vanilla returns.
crewswap.ci.ctorxmlBarotrauma.CharacterInfo..ctor(ContentXElement, Identifier)AfterRead them back when an info is built from XML (into CS.pending).
crewswap.mpc.loadBarotrauma.MultiPlayerCampaign.Load(XElement)AfterRead <CrewSwap>, rebuild the registry, detect duplicate ids, fill gaps from the mirror. GameMode is not set yet here, so it works on the patch instance.
crewswap.mpc.save.preBarotrauma.MultiPlayerCampaign.Save(XElement, Boolean)BeforeReturns early unless CS.campaignOk. Detect a new campaign; bind every live crew member to a sid (minting new ones only while active) before CharacterInfo.Save runs.
crewswap.mpc.save.postsameAfterWrite <CrewSwap> into the MultiPlayerCampaign element; write the mirror file.

Switching, joining and the crew-down window

IdTargetWhenWhy
crewswap.joinBarotrauma.Networking.GameServer.OnInitializationComplete(NetworkConnection, String)AfterReliable “this client finished joining”, used for rejoin placement. LuaCs’s client.connected reports the last connected client, which can be the wrong one when two join together.
crewswap.crewdown.preBarotrauma.Networking.GameServer.Update(Single)BeforeWhile the grace window is open, set UsingFreeCam on clients so vanilla’s crew-down test sees someone up.
crewswap.crewdown.postsameAfterClear the flags again (a think hook does too, in case Update threw).

Round end, keep-dead and wallets

IdTargetWhenWhy
crewswap.endroundBarotrauma.GameSession.EndRoundBeforeMark the round as ending; snapshot each client’s home body before CampaignMode.End despawns the dead.
crewswap.crewsetBarotrauma.GameSession.GetSessionCrewCharactersAfterAdd released home bodies to the crew set when bots are included, so idle homes get mission XP and round-end talents like bots.
crewswap.endguardBarotrauma.CampaignMode.End(CampaignMode+TransitionType)BeforeRaise the keep-dead guard; stage bot wallets.
crewswap.endguard.postsameAfterLower the guard.
crewswap.keepdeadBarotrauma.CrewManager.RemoveCharacterInfo(CharacterInfo)BeforeWhile the guard is up, skip the removal of a dead non-home crew member (PreventExecution).
crewswap.gameoverBarotrauma.MultiPlayerCampaign.End(CampaignMode+TransitionType)AfterOn save-and-quit, put back the pre-restore GameOver value.
crewswap.restoreBarotrauma.MultiPlayerCampaign.SavePlayersBeforeThe restore, then keep-dead decisions and the pre-save integrity check.
crewswap.restore.postsameAfterPut kept crew back, revive them, post-save integrity check.
crewswap.endgameBarotrauma.Networking.GameServer.EndGameAfterOn an unsaved end, revive kept crew without a penalty and drop staged wallets.
crewswap.wallets.saveBarotrauma.SaveUtil.SaveGame(CampaignDataPath, Boolean)BeforeCommit staged wallets into records for a save that is really written.
crewswap.wallets.initBarotrauma.CrewManager.InitRoundAfterRestore bot wallets at round start.

Death

IdTargetWhenWhy
crewswap.kill.preBarotrauma.Character.KillProjSpecificBeforeHide the driver (driver.Character = nil) so vanilla’s driver-keyed death effects (ApplyPermadeath, SpectateOnly, RetainExperience, the traitor message) do not hit the wrong character.
crewswap.kill.postsameAfterGive the driver back and apply the effects to the character that died; undriven permadeath.
crewswap.respawn.detachBarotrauma.Networking.RespawnManager.RespawnCharacters(RespawnManager+TeamSpecificState)BeforeOutside Permadeath, let go of a dead borrowed corpse so respawn does not despawn it.
crewswap.retainxp.saveBarotrauma.MultiPlayerCampaign.SaveExperiencePointsBeforeSkip it: RetainExperience is keyed per client, which switching would turn into free XP.
crewswap.retainxp.getBarotrauma.MultiPlayerCampaign.GetSavedExperiencePointsBeforeReturn 0 (an int method: the number is the Lua return value).
crewswap.takeoverBarotrauma.Networking.GameServer.ReadTakeOverBotMessageBeforeRoute vanilla TAKEOVERBOT through the CrewSwap switch; let hire/bench ids through only where they cannot discard a living home.
crewswap.takeover.postBarotrauma.Networking.Client.TryTakeOverBotAfterWhen vanilla did take a bot over, record it as the player’s new home.

Safety

IdTargetWhenWhy
crewswap.safety.killBarotrauma.Character.KillProjSpecificAfterNote a Permadeath crew death as a legitimate removal.
crewswap.safety.fireBarotrauma.CrewManager.FireCharacter(CharacterInfo)BeforeNote a firing as legitimate.
crewswap.safety.discardBarotrauma.MultiPlayerCampaign.DiscardClientCharacterDataBeforeNote vanilla’s CCD discard (“create new character”, takeovers) as legitimate.
crewswap.safety.saveBarotrauma.SaveUtil.SaveGame(CampaignDataPath, Boolean)BeforePrevent the disk write when a check failed.
crewswap.safety.revertBarotrauma.Networking.GameServer.EndGameAfterAfter a blocked write: lock down, reload the last good save.
crewswap.safety.load.preBarotrauma.MultiPlayerCampaign.LoadCampaign(CampaignDataPath, Client)BeforeLift an integrity lock for the duration of a load so save-on-load writes the ids.
crewswap.safety.loadsameAfterPut the lock back; take a new baseline; lock on duplicate ids in the loaded save.
crewswap.safety.lobbyBarotrauma.Networking.GameServer.TryStartGameBeforeRefuse to start a round while an integrity lock holds (including while it is lifted for a load), or inside a load whose save has duplicate ids.

Admin

IdTargetWhenWhy
crewswap.hire.ownerBarotrauma.CampaignMode.TryHireCharacterAfterIn own pool mode the hirer owns the new hire (not a Permadeath replacement, which becomes the buyer’s home).

Client patches

Installed directly with Hook.Patch inside pcall by client/main.lua (a failure sets CSC.probesOk = false instead of switching anything off, and the hello reports it):

IdTargetWhenWhy
crewswap.clickBarotrauma.CrewManager.CharacterClickedBeforeA click on a Free crew row sends a switch request and prevents vanilla’s click. A click on a not-Free, undriven row also sends a request (so the denial explains why) but lets vanilla’s click run.
crewswap.rowBarotrauma.CrewManager.AddCharacterToCrewListAfterRemember each row and style it (greyed and labelled when taken, dimmed when not Free for this player).

The client also calls LuaUserData.MakeFieldAccessible on CrewManager.crewList (through a RegisterType of CrewManager) to reach the crew list’s content, and looks up seven static classes with CreateStatic (Timing, CoroutineManager, ConversationAction, HintManager, TabMenu, GameSession, ChatBox). Any of these failing also sets CSC.probesOk = false.

Events (Hook.Add)

EventUsed for
roundStartActive recompute, notices, spawn audit, live-list check, placement, state broadcast, integrity snapshot, per-round resets (server); control tracker reset (client).
roundEndClient only: forget crew-list rows. Not used on the server, because LuaCs also fires it on GameSession.LoadPreviousSave.
thinkJoin poll, hello watchdog, death and crew-down safety nets (server); control-change detection and row rebuilds (client).
chatMessage!crewswap commands; returning true swallows the message.
client.disconnectedRelease the body; forget the client’s hello state.
character.createdRespawn AI audit; bot wallet restore for bodies created outside InitRound.
character.deathBroadcast state when a crew member dies.

Accessibility and registration

Server Lua must register some types before use, and a few private members are opened:

  • LuaUserData.RegisterType for System.Version, AssemblyInfo, System.Guid, Barotrauma.MultiPlayerCampaign, Barotrauma.SaveUtil, Barotrauma.IO.Directory, System.Collections.Immutable.ImmutableHashSet`1 (the open generic; closed types inherit it) and Barotrauma.Networking.GameServer+TryStartGameResult.
  • MakeFieldAccessible(MultiPlayerCampaign, 'characterData'): the list of CCDs.
  • MakePropertyAccessible(MultiPlayerCampaign, 'GameOver'): it has a private setter.
  • Selftests also register Barotrauma.CrewManager, Barotrauma.CampaignMode, Barotrauma.CharacterCampaignData, Barotrauma.Wallet and Barotrauma.Networking.IReadMessage to probe their members.
  • Enum tables come from LuaUserData.CreateEnumTable for Barotrauma.MultiPlayerCampaign+NetFlags, Barotrauma.Networking.RespawnMode, GameServer+TryStartGameResult and Barotrauma.CharacterType.

LuaCs gotchas

These were learned from the LuaCs source for the targeted build and from testing. Most of them fail silently if ignored, so they are worth reading before touching any patch.

Read arguments with brackets

Arguments live in the parameter table under their exact C# parameter names, which are case-sensitive: p['characterInfo'], p['causeOfDeath'], p['isSavingOnLoading']. An unknown or misspelt name throws inside the patch (and, in a prefix, fails open). The table also has members of its own: ReturnValue, PreventExecution, OriginalReturnValue.

In Lua p.x and p['x'] compile to the same lookup, so this is a convention rather than a different operation, but it is one the code follows everywhere: brackets for the patched method’s arguments, dots for the table’s own members. That makes it obvious at a glance which names must match the C# signature exactly, and keeps an argument that happens to share a name with a member from being misread.

Bare return on non-void methods

A callback’s own return value becomes the method’s result on a non-void method. return nil is not “no value”: it sets the result to null (and a null for a value-type return faults). End callbacks with a bare return or no return at all, and only return a value deliberately:

  • crewswap.retainxp.get returns 0 to replace an int;
  • crewswap.safety.lobby returns TryStartGameResult.GameAlreadyStarted;
  • the client’s crewswap.click returns true after setting PreventExecution.

This is why most callbacks are wrapped by a helper (safe, guard) or an inline pcall and return nothing. The few trivial flag-setting prefixes (crewswap.retainxp.save, crewswap.safety.save, crewswap.safety.load.pre) are unwrapped, and crewswap.retainxp.get, crewswap.safety.lobby and the client’s crewswap.click return values deliberately.

Numbers need wrappers when written back

Writing a Lua number into a parameter stores a boxed double, and the write-back does no numeric conversion, so an int, ushort or float parameter throws InvalidCast. Wrap them (Int32(x), UInt16(x), Single(x)), or prefer the callback’s return value, which is converted properly. CrewSwap currently writes back only a Boolean (p['hasAi'] = true), which needs no wrapper.

pcall (almost) every patch

  • A wrong signature after a game update throws at Hook.Patch time. P.patch catches it and turns it into CS.fail.
  • A Lua error inside a prefix fails open: the original method still runs. Never let save-critical safety depend on a prefix succeeding without a later check. This is why the spawn audit verifies forceai, and why the post-save integrity check exists at all.
  • Postfixes run even when a prefix set PreventExecution, so a postfix checks the module’s own state or p.OriginalReturnValue rather than assuming the original ran.

Enums are numbers

Enum values reach Lua as numbers (or as boxed values of the enum type, which compare equal). Compare against CauseOfDeathType.Disconnected, CharacterTeamType.Team1 or a LuaUserData.CreateEnumTable('Barotrauma.Networking.RespawnMode') entry, never against tostring(x) == 'Name'. Flag enums are tested arithmetically (the crew-set postfix uses ty % 2 == 1 for the Bot flag). The selftest enum-numbers checks this assumption.

CLR collections arrive as copies, iterators or userdata

A C# List<T> reaches Lua as a copied table: mutating it changes nothing in the game (Inventory.DraggingItems has no usable Clear for this reason). Converted IEnumerables arrive as iterator functions, and some results (a LINQ Concat, an immutable set) arrive as userdata enumerables. Always go through G.list(x), which handles all three, and never mutate the result expecting the game to see it. Immutable collections return a new instance from Add, so the crew-set postfix assigns p.ReturnValue = result:Add(ch).

Interface parameter types do not resolve

A parameter-type string naming an interface (for example Barotrauma.Networking.IReadMessage) never matches. Where the method name is unique, omit the type list entirely (as for ReadTakeOverBotMessage). Overloads need the full list with CLR names: nested types use + (Barotrauma.CampaignMode+TransitionType), and by-ref parameters a ref or out prefix.

Hook.RemovePatch is broken

In the targeted LuaCs build, Hook.RemovePatch passes the class name as the identifier, so it only removes a patch whose identifier happens to equal its class name. CrewSwap never unpatches; it gates every callback on mod state instead (see the top of this page). (A Lua reload or server stop clears all Lua patches anyway.)

Inlined methods can bypass a patch

A tiny method that the JIT inlines into its caller is not patched at that call site. CrewSwap hit this with CrewManager.RemoveCharacterInfo inside SavePlayers, and works around it by deciding in the SavePlayers prefix and repairing in its postfix (see Lifecycle).

Other things to know

  • Private methods on a base class are not found; name the declaring class.
  • Coroutine bodies cannot be usefully patched (only the enumerator factory is); hook the observable effects instead.
  • Time is LuaUserData.CreateStatic('Barotrauma.Timing').TotalTime.
  • require is shared by every mod, hence the crewswap. prefix on every module.
  • Sandboxed client Lua can still Hook.Patch any game type, but can only register Barotrauma.* and a few system types; AssemblyInfo, for example, is out of reach.

Networking

The client half of CrewSwap is small: it shows which crew members are taken, turns a click on a Free row into a request, and tidies up after the player’s own control changes. Everything else is decided on the server. The two halves talk through LuaCs Networking messages.

How the mod reaches clients

The whole mod/CrewSwap/ folder is a content package that the server requires and sends to every joining client (or that players subscribe to on the Workshop). Lua files do not count as multiplayer-synced content, so the package contains one synced file, Content/crewswap_sync.xml, an empty <Items></Items> item file. That is enough for the game to treat the package as required and to offer the download.

The package hash covers content files, the name and modversion, not the Lua. A code change without a modversion change would let clients keep a cached copy with stale scripts. That is why every release gets a new modversion (stamped by tools/package.sh) and why the client and server also compare BUILD in the hello.

A client without client-side LuaCs still receives the package but never runs its Lua. It can play the campaign as vanilla; it just cannot switch.

Messages

All messages are reliable LuaCs net messages. Characters are identified by entity id (Character.ID, a UInt16). Account ids and stable ids are never sent to clients; the only player-identifying data that goes out is the controller’s display name, which every client can see in the crew list anyway.

NameDirectionPayloadMeaning
crewswap.helloclient → serverString BUILD, Boolean probesOk“My CrewSwap Lua is running, this is my build, and my patches installed.”
crewswap.hello?server → client(empty)“I have no hello from you, please send one.”
crewswap.stateserver → clientUInt16 count, then per crew member: UInt16 entity id, String controller name ('' if none), Boolean freeThe crew as this client should see it.
crewswap.requestclient → serverUInt16 entity id“I want to drive this character.”
crewswap.deniedserver → clientString reason, Single extraA request was refused; extra is the seconds left for cooldown, otherwise 0.

The crewswap.state layout is implemented once, in core/statecodec.lua, and used by both sides.

Hello and state sync

LuaCs drops a server-to-client message that arrives before the client’s Lua has registered a handler for it, and a client message sent before the server lists the sender as connected is lost. The handshake is shaped around both:

 client                                       server
 ------                                       ------
 load: register crewswap.state / denied
       handlers FIRST
 probe-install patches (sets probesOk)
 register crewswap.hello? handler
 +2 s: crewswap.hello(BUILD, probesOk)  -->   clientBuild[key] = BUILD
       (resent every 5 s, up to 12 times,     BUILD ~= server BUILD or not probesOk:
        until the first crewswap.state)         clientBad[key] = true, tell "out of date"
                                         <--  crewswap.state (this client's view)
 applyState: gotState = true, stop resending
  • Why resend. A hello that lands before the client is in the server’s connected list (a slow join, a mod download) is lost. The client resends every HELLO_RETRY_MS = 5000 ms, at most HELLO_TRIES = 12 times, until any crewswap.state answers.
  • Why the server asks. A server Lua reload forgets every hello, while the clients’ Lua keeps running and would never resend. So the server’s think watchdog sends crewswap.hello? to any in-game client it has no hello from, every HELLO_ASK_EVERY = 5 s, at most HELLO_ASKS = 12 times; the client answers with a fresh hello. The hello tables also survive a server Lua reload (server/main.lua copies them), and any carried-over hello from a different build is marked bad.
  • No client-side LuaCs. If a client has been in game for NO_HELLO_AFTER = 90 s with no hello while the mod is active, it is told once: [CrewSwap] This server uses CrewSwap. Turn on client-side LuaCs (LuaCs settings), then rejoin to switch between crew. It is not kicked.
  • Stale or broken copies. A hello whose BUILD differs from the server’s, or whose probes failed, marks the client bad. It is told [CrewSwap] Your copy of CrewSwap is out of date or didn't start properly — rejoin to update., and every request it sends is denied as inactive.
  • Rejoining starts over. client.disconnected forgets the client’s hello state; the new session sends a fresh hello.

When state is sent

SW.broadcastState() sends every client that has said hello its own view of the crew: after every switch and release, on every round start, on any crew death (character.death), after a driver dies, around the round-end restore, after a vanilla bot takeover, after a switch that failed, and when an admin changes pool, protect or an owner. SW.sendState(client) answers a hello. Clients that never said hello get nothing (nothing would read it).

The state is per client because Free depends on who is asking: under protect, another player’s home is not Free for you; in own mode, someone else’s character is not Free for you. The server computes rules.isFree(view, toggles, requesterKey) for each recipient. When the mod is inactive, nothing is Free.

Click handling

CSC.onClick is a prefix on Barotrauma.CrewManager.CharacterClicked, the method the crew-list row’s own click path calls (the per-row order button and right-click menus use other paths, so they stay vanilla).

 click on a row
   not multiplayer, no client, not a Character?  -> vanilla
   my own current character?                     -> vanilla
   no state for it (not crew / mod not running)? -> vanilla
   not Free for me:
     nobody drives it and no dialogue/camera
     transition?   -> send request (the server's denial says why), vanilla click still runs
     otherwise     -> vanilla (the row already shows who has it)
   Free:
     PreventExecution
     conversation open?        -> local "Finish the conversation..." message
     camera transition?        -> ignore silently
     within 3 s of last switch -> local "Too fast, wait N more second(s)..." message
     otherwise                 -> crewswap.request(id)

Design decisions in the click path:

  • Ask anyway for dimmed rows. A crew member that is not Free for this player (another player’s protected home, someone else’s in own mode, unconscious) still sends a request so the server’s denial tells the player why. The cost is a denial message per click while the mod is inactive, which is accepted as useful feedback.
  • No switch during a camera transition. CameraTransition restores its previous character when it ends, which would undo the switch and desync input. The client checks CoroutineManager.IsCoroutineRunning('CameraTransition') and the end-of-round cinematic.
  • Dragging is safe. Rows can be reordered by dragging. Vanilla’s list treats a drag as a reorder and only reports a click for a press and release in the same position, so a drag never sends a request. Row rebuilds are also skipped while a row is being dragged.
  • The client cooldown is a courtesy. It saves a round trip; the server enforces the same 3 s.

Row styling

CSC.onRowAdded (postfix on CrewManager.AddCharacterToCrewList, whose return value is the new row, or nil if the row already existed) records the row and its original colours. CSC.styleRow then:

  • taken (state has a controller name): row, hover and name greyed, plus a right-aligned label with the controller’s name;
  • not Free for this player: name dimmed;
  • otherwise: the original colours.

Vanilla only builds objective icons for rows that were bots when created, so every 0.5 s checkFlips rebuilds (remove and re-add at the same index) any row whose character’s IsPlayer changed since it was built. Rows built before the client’s Lua started (for example after a client reloadlua) are adopted from the state on the next restyle.

Own control change

CSC.think watches Game.Client.Character. When it changes from one non-nil character to another, the client sets its local cooldown, shows [CrewSwap] Now playing <name> and runs the housekeeping that singleplayer’s character selection does and multiplayer skips: close the command UI, clear the inventory drag slot, HintManager.OnChangeCharacter(), refresh an open Talents tab, and reset or auto-hide the crew list and chat box depending on whether the new body is operating something. Each step is wrapped separately, so one failing call cannot block the others or re-fire every frame.

Denials

Server-side denials use crewswap.denied for clients that said hello (shown on screen and in chat, in red) and a private server chat line for clients that did not. The wording comes from core/rules.message, shared by both sides, so the client never needs its own copy of the text.

Testing

CrewSwap is tested at three levels:

  1. Unit tests (294 at 0.9.0) run off-game with stock Lua 5.2 against the pure core modules and, with fake game globals, against the server and client adapters.
  2. An in-game selftest (23 checks plus one live check) proves, on a real server, that every API the mod relies on exists and behaves as assumed.
  3. Manual play tests with real clients, following the checklist table below.

At 0.9.0 the server-side logic has passed all unit tests, all selftests and an automated 18-scenario run against a real dedicated server (transitions, save-and-quit, unsaved ends, kept dead crew, wallets through restarts and Lua reloads, Permadeath, Ironman, firing, lockdown and recovery). Live two-player switching has not yet been fully play-tested, which is why the release is labelled beta.

Unit tests

Running them

From the repository root:

lua5.2 tests/run.lua

On Debian or Ubuntu, sudo apt install lua5.2. The runner prints PASS <name> / FAIL <name>: ... for every test, then N passed, M failed, and exits non-zero on any failure.

Why Lua 5.2: LuaCs runs MoonSharp, which implements Lua 5.2 semantics (all numbers are doubles, no integer division operator, no utf8 library, goto and bit32 available). Testing against stock 5.2 catches the same syntax and semantics mistakes. Do not use 5.3+ features in mod code.

How the runner works

tests/run.lua sets package.path to mod/CrewSwap/Lua/?.lua;tests/?.lua;..., so tests load the real modules with the same require('crewswap.core.rules') names the game uses. It runs every tests/test_*.lua in sorted order. Each file returns a table of named test functions; each function gets a small assertion helper T. An illustrative test file (the test names are examples, not real tests in the suite):

local rules = require('crewswap.core.rules')
local revive = require('crewswap.core.revive')
local F = require('fakes')

return {
  ['dead crew is not Free'] = function(T)
    T.eq(rules.isFree(F.view({ alive = false }), { pool = 'shared', protect = true }, 'acct:A'), false)
  end,
  ['revive.plan rejects an unknown cause'] = function(T)
    T.err(function() revive.plan({ cause = 'bogus' }, 'transition') end)
  end,
}
  • T.eq(actual, expected, msg), T.ok(value, msg), T.err(fn, msg) (expects fn to raise).

Pure core modules

The core/ modules take and return plain tables, so their tests are direct: build an input, call the function, compare the output. They cover, for example, the order of denial reasons, own-pool claiming, placement with competing claims, the mirror’s two-pass match with ambiguous keys, every integrity problem kind with and without legitimate removals, and every revive path.

tests/fakes.lua

Shared doubles:

  • F.view(o): a Free, eligible crew view; override any field with o.
  • F.requester(o): an eligible switch requester.
  • F.buffer(): a network message double with Write*/Read* for UInt16, String, Boolean and Single. A read of the wrong type, or past the end, raises, so codec mismatches fail loudly.

Adapter tests with fake game globals

The server and client modules call game globals (Game, Hook, LuaUserData, Networking, Character, Client, …). Their tests (test_switch.lua, test_roundend.lua, test_death.lua, test_safety.lua, test_client.lua and others) build a small fake world in _G per test:

  • clear the module from package.loaded and re-require it, so each test starts clean;
  • install fakes that record what the module did (Hook.Add and Hook.Patch capture the callbacks, SetClientCharacter records calls, Networking.Send records messages);
  • call the captured callbacks directly, the way the game would, and assert on the recorded effects.

LuaUserData.IsTargetType is faked by giving fake objects a types set, so code that checks IsTargetType(ch, 'Barotrauma.AICharacter') works unchanged. These tests cover the call sequences that matter: the two-step SetClientCharacter, the restore order, keep-dead and revive on each save path, wallet staging and commit, the kill redirect, the block-reload-lock sequence and the client’s click guards.

What unit tests cannot prove: that a patch target resolves in the real game, that a CLR type converts the way the fake assumes, or how vanilla code reacts. That is the selftest’s job.

In-game selftest

Enabling it

Create an empty file named selftest in the CrewSwap folder of the server’s save folder (Game.SaveFolder, the folder that contains Multiplayer/):

touch "<SaveFolder>/CrewSwap/selftest"

Restart the server. One second after load, the server console shows a line per check and a summary:

[CrewSwap] SELFTEST PASS timing
[CrewSwap] SELFTEST PASS enum-numbers
...
[CrewSwap] SELFTEST done: 23 passed, 0 failed, 0 skipped

Delete the file to turn it off. The selftest runs before any campaign is loaded; it creates throwaway CharacterInfo objects and temporary files and cleans up after itself, but config-roundtrip briefly rewrites config.txt (and puts the real settings back), so run it on a test server or between sessions.

The checks

Each module registers its own checks with ST.add(name, fn). A check returns true (PASS), 'skip', why (SKIP), or anything else / raises (FAIL).

ModuleChecks
selftesttiming (Timing.TotalTime is a number), enum-numbers (CauseOfDeathType.Disconnected is a number)
gamelist-convert (G.list on Character.CharacterList and a plain table)
spawncreate-sig-resolves
identityci-save-attr (CharacterInfo.Save writes crewswap_*), ci-ctor-read (the XML constructor reads them back), mirror-io (mirror write/read round trip, no .tmp left)
switchstate-encode, ccd-api (SetClientCharacterData, GetClientCharacterData, RemoveCharacterInfo exist), crew-views
presencejoin-patch-resolves
roundendroundend-patches, crew-set-add (immutable set Add/Contains/Count behave), crew-set-postfix (the flag enum arrives as a usable number), revive-apply (the revive on a real CharacterInfo)
deathdeath-patches, death-api (ApplyPermadeath, Bank, IncrementLastUpdateIdForFlag, wallet and IReadMessage members)
safetysafety-patches, safety-backups (MaxBackupCount is 20), safety-api, safety-runcheck
adminconfig-roundtrip, hire-signature

Separately, on the first round of any campaign, G.checkLiveList() prints SELFTEST PASS live-crew-list or SELFTEST FAIL live-crew-list (which also switches the mod off). This one always runs, with or without the marker file.

Other console lines worth checking after a change

  • [CrewSwap] patches installed: 36/36
  • [CrewSwap] round start, active: true in a campaign round
  • no [CrewSwap] FAIL lines and no ... error: lines
  • identity: save: minted 0 new ids for N crew on saves after a restart (non-zero only right after new hires)

Manual test checklist

The behaviours that need real players are tested by hand on a throwaway campaign. The table below is the checklist: each row is an area to play through, with the chat and console lines described on the guide pages (Playing, Admin commands, Troubleshooting) as the expected results.

AreaWhat is checked
Install and handshakeThe client downloads the mod and logs the same build as the server; hello sent ... probes ok; a client without client-side LuaCs gets the 90 s notice and is not kicked.
ActivityActive in a normal multiplayer campaign; inactive (with the notice) in Ironman; inactive in sandbox and mission rounds, including a sandbox round right after a campaign; the version-mismatch and FAIL notices.
SwitchingClick a bot to take it; “Now playing”; taken rows greyed and labelled; the body you left resumes its orders; 3 s cooldown; dragging rows and order buttons do not switch; two players clicking the same bot at once; no switching during the round-end summary; first-time joiners must spawn once.
PresenceDisconnect hands the body to the AI (and it is not killed later); rejoin with protect on and off; round-start placement with protect on (everyone home) and off (last played).
Round endLevel transition and save-and-quit with players in borrowed bodies: everyone stored once, items and skills kept; idle homes get mission XP.
Dead crewA dead bot comes back next round with the skill penalty (transition) or without (unsaved end); items in a duffel bag after a transition.
WalletsA bot’s money survives a transition and a restart; an unsaved end reverts it with the player characters’.
DeathDeath in a borrowed body: corpse stays, switch on at once; vanilla respawn of the home; Permadeath: the dead character is permanently dead, the driver is not made a spectator, homes’ money banked, a new home adopted; undriven permadeath.
Crew downThe 120 s grace window with its three messages; switching in cancels it; nobody switching ends the round as vanilla.
SafetyThe forced integrity failure: nothing written, reload, lobby held, !crewswap check lifts it; data kept through a lockdown; firing and permadeath across two rounds produce no false alarm; vanilla “create new character” produces no false alarm.
AdminCommands never appear in chat; non-admins refused; settings persist across a restart; own pool: hiring, first-play claims, owner reassignment, hires made before own mode.
RestartsA server restart and a Lua reload mid-campaign keep every character’s id, level and money.

If you run any of these rows on your own server, an issue per row describing what you saw (with the relevant [CrewSwap] console lines) is extremely helpful, especially for the two-player items.

Contributing

Thanks for wanting to help. Bug reports, play-test results and pull requests are all welcome at github.com/MrMcEpic/barotrauma-crewswap.

The most valuable contribution right now is two-player testing: the server side is well covered, but live switching between several players has not been fully play-tested yet. If you try it, an issue saying what worked and what didn’t (with the [CrewSwap] lines from the server console and the client’s F3 console) helps a great deal.

Before changing code, read the Architecture overview and Patches and LuaCs gotchas. Most of the subtle bugs this mod could have are about save paths and silent LuaCs behaviour, and those pages explain both.

Repository layout

mod/CrewSwap/          the mod itself; this folder is what ships (to the server and every client)
  filelist.xml, ModConfig.xml, Content/crewswap_sync.xml
  Lua/crewswap/{shared,core,server,client}/
tests/                 off-game unit tests (run.lua, fakes.lua, test_*.lua)
tools/package.sh       builds a release zip
book.toml              mdBook configuration (src = "docs")
docs/                  this documentation (mdBook sources: SUMMARY.md, guide/, architecture/)
theme/                 extra CSS for the book
landing/               the landing page served at the site root (the book is served under /book/)
workshop/              Steam Workshop description and assets
README.md, CHANGELOG.md, CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, LICENSE
                       root project files (CHANGELOG.md feeds the GitHub release notes)
.github/ISSUE_TEMPLATE/, .github/PULL_REQUEST_TEMPLATE.md   issue and PR templates
.github/workflows/     ci.yml (tests, package, XML), docs.yml (book + Pages), release.yml (tag-driven release)

dist/ (release zips), book/ (the built book, written by mdbook build) and _site/ (the assembled Pages site) are build outputs; they are gitignored and never committed.

Development setup

You need:

  • Lua 5.2 for the unit tests (sudo apt install lua5.2 on Debian/Ubuntu; most package managers have a lua5.2 or lua@5.2 package). LuaCs runs MoonSharp, which implements Lua 5.2, so stick to 5.2 features.
  • bash and zip for packaging.
  • mdBook if you edit the documentation (mdbook serve from the repository root previews it locally).
  • For in-game testing: a Barotrauma dedicated server and client of the targeted version, with LuaCsForBarotrauma installed server-side and client-side LuaCs enabled on the client. See Requirements.

Running the tests

From the repository root:

lua5.2 tests/run.lua

All tests must pass before a pull request is merged. The runner exits non-zero on any failure, so it works as a CI step too. See Testing for how the tests are structured and how to write new ones.

Packaging

tools/package.sh 0.9.0          # BUILD = 0.9.0
tools/package.sh 0.9.0 beta     # BUILD = 0.9.0-beta

tools/package.sh <version> [build-suffix] copies mod/CrewSwap to a staging folder (your working tree is never modified), stamps MODVERSION and BUILD into Lua/crewswap/shared/build.lua and modversion into filelist.xml, and writes dist/CrewSwap-<version>.zip with a single top-level CrewSwap/ folder.

Two things to keep in mind:

  • Bump the version for every code change you deploy. The game’s package hash ignores Lua files, so clients with a cached copy of the same modversion would keep running old scripts.
  • The server and every client must run copies of the same package. The client’s hello carries BUILD and the server refuses switch requests from any other build. The Workshop upload should be the contents of the zip.

Testing a build on a server

  1. Stop the server.
  2. Unzip dist/CrewSwap-<version>.zip into the server’s LocalMods/ folder (replacing any older CrewSwap/).
  3. Make sure the package is enabled for the server (see Server installation).
  4. Start the server and check the console for [CrewSwap] server loaded build <BUILD>, patches installed: 36/36 (or the current total) and no FAIL lines.
  5. Optionally enable the in-game selftest.

Use a throwaway campaign for testing anything save-related, and keep a copy of the server’s Multiplayer/ saves.

Releasing

Releases are cut by pushing a tag. Three GitHub Actions workflows are involved:

  • ci.yml runs on pushes to main that touch code (docs-only and prose-only changes are skipped), on every pull request and on manual dispatch: the Lua 5.2 unit tests, a tools/package.sh 0.0.0 ci-1 package smoke test and an XML well-formedness check of the mod.
  • docs.yml runs when docs/, book.toml, theme/ or landing/ change: it builds the book with mdBook (any mdBook warning fails the build), puts the landing page at the site root and the book under /book/, and deploys the result to GitHub Pages on pushes to main.
  • release.yml runs when a v* tag is pushed: for example, pushing v0.9.0-beta runs the tests, runs tools/package.sh 0.9.0 beta and attaches CrewSwap-0.9.0.zip to a GitHub release. A tag with a -suffix becomes the BUILD suffix and is marked as a pre-release.

The GitHub release notes come from the matching ## [x.y.z] section of the root CHANGELOG.md (for v0.9.0-beta, the ## [0.9.0] section). If that section is missing, the release only gets a link to the book’s changelog, so add the section before you push the tag.

Tag the 0.9.0 beta as v0.9.0-beta. A plain v0.9.0 tag would publish it as a full release with a BUILD of 0.9.0, which doesn’t match the in-tree 0.9.0-beta.

One-time setup for a fork or fresh repo: in the GitHub repo, set Settings > Pages > Source to “GitHub Actions”, or the Pages deploy job fails.

Coding conventions

Structure

  • Put decisions in core/ as pure functions over plain tables, with unit tests. Keep server/ and client/ modules as thin adapters that read the game, call core, and apply the result.
  • Namespace every module crewswap.<area>.<name>: the require cache is shared by every mod.
  • Never write runtime files inside mod/CrewSwap/: that folder ships to every client. Server data goes under Game.SaveFolder .. '/CrewSwap/' or next to the campaign save.
  • Pure, sandboxed Lua only: no <Assembly>, no RunUnrestricted="true". Client code must work both sandboxed and unsandboxed.

Patches

  • Install every server patch with P.patch(...), never a bare Hook.Patch, so a failure to resolve switches the mod off cleanly.
  • Gate every callback on mod state, normally CS.active (identity: CS.campaignOk); keep the existing non-CS.active gates on the restore, safety and load paths as they are (see Patches). Patches are never removed.
  • Wrap every callback body in pcall (the existing safe / guard helpers) and end with a bare return. Return a value only when you mean to replace a non-void method’s result.
  • Read arguments as p['exactCSharpName']; read the table’s own members with dots (p.ReturnValue, p.PreventExecution).
  • On a save-critical path, an error must set CS.blockSave, not be swallowed.
  • Compare enums with enum tables, never with strings. Convert CLR collections with G.list. See the full list of gotchas.
  • Add a selftest check (ST.add) for any new game API or patch target you rely on.

Text and logging

  • Player-facing text starts with [CrewSwap] and uses Canadian English (colour, behaviour, licence as a noun, -ize endings). Code identifiers keep the game’s US spelling.
  • Module log lines use [CrewSwap] <module>: <message>, so they are easy to grep. Status and start-up lines are the exceptions: for example FAIL:, OFF:, WARNING:, INTEGRITY ..., SELFTEST ..., the grace countdown, the boot lines (server loaded build, running on game, save folder:, modules loaded:, patches installed:, active:) and round start, active: .... Every console line starts with [CrewSwap].
  • Keep the denial wording in core/rules.lua so the server and client stay in sync.

Style

  • Two-space indentation, local everything, short comments that explain why (vanilla behaviour, a LuaCs quirk) rather than what.
  • Match the surrounding code; small, focused changes are easier to review than broad rewrites.

Updating for a new game or LuaCs version

When the game updates, CrewSwap switches itself off (OFF: version mismatch). When LuaCs updates, it only logs [CrewSwap] WARNING: built for LuaCs <expected>, running on <found>; continuing (patch audit and self-tests still guard) and keeps running, relying on the patch-install audit and the start-up and runtime checks to catch a breaking change. Either way, supporting the new version properly is careful work, because a patch that still resolves can still be wrong:

  1. Read the game and LuaCs changelogs.
  2. For every patch in Patches, check the target method in the new source: signature, parameter names, and the behaviour the patch depends on (for example the order of steps inside GameSession.EndRound, or what SavePlayers removes).
  3. Update EXPECT_GAME and/or EXPECT_LUACS in mod/CrewSwap/Lua/crewswap/shared/build.lua.
  4. Run the unit tests, deploy to a test server, run the selftest, and play through the save-related rows of the manual test checklist on a throwaway campaign.

Never bump the expected versions without re-checking: the game version gate is what keeps a broken build from touching anyone’s campaign, and the LuaCs warning is what tells admins they are running an untested combination.

Pull requests

  • Describe what the change does and why. For behaviour changes, say what you tested in game (server only, or with clients; which respawn mode; which round-end path).
  • Include unit tests for new or changed logic. Keep lua5.2 tests/run.lua green.
  • Update the relevant pages in docs/ and add an entry to both changelogs: the root CHANGELOG.md (which feeds the GitHub release notes) and the book’s Changelog.
  • Do not include personal details (server addresses, account ids, player names) in code, tests, logs or screenshots you attach. Use placeholders such as Alice, Bob or your-server.example.com.
  • AI-assisted contributions are fine, just as the mod itself was built with AI assistance; please review and test what you submit as carefully as anything you wrote by hand.

Licence

CrewSwap is licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only); see the LICENSE file. By submitting a contribution you agree that it is licensed under the same terms.

In practice, for a Barotrauma mod: you may use, study, modify and share it. If you distribute a modified version (for example on the Workshop), or run a modified version on a server that other people play on, the AGPL asks you to make your modified source available to those people under the same licence. Since the mod’s Lua is sent to every client anyway, the simplest way is to publish your fork openly and link to it.

Changelog

All notable changes to CrewSwap are listed here. Versions follow the modversion of the content package.

0.9.0 (beta)

First public release.

Beta status. All server-side logic is covered by unit tests and the in-game selftest (23 checks), and the server side passed an automated 18-scenario run against a real dedicated server. Live switching between two or more players has not yet been fully play-tested. Please use it on campaigns you can afford to restore from a backup, and report what you find.

Targets Barotrauma 1.13.4.x and was tested against LuaCsForBarotrauma (Workshop id 2559634234) at the commit recorded in shared/build.lua. Every player needs client-side LuaCs to switch; the server needs LuaCs installed server-side.

Features

  • Switch between crew members in a multiplayer campaign by clicking a row in the crew list, singleplayer-style. The body you leave becomes a working bot again and resumes its orders.
  • Taken crew members are greyed out and labelled with the name of the player driving them; crew you cannot take are dimmed.
  • Two settings, changed in game by admins and saved across restarts:
    • Crew pool: shared (anyone can take any Free crew member) or own (you can take your own character, crew you hired while the pool was own, crew you claimed by playing them first, crew an admin gave you, and crew nobody has claimed yet).
    • Home protection (on by default): your own character can only be driven by you.
  • When the character you are driving dies, jump straight into another Free crew member.
  • The round is held open for up to 2 minutes when every player is down but Free crew remain.
  • Non-home crew members who die outside Permadeath come back next round (with the vanilla skill penalty when the round saves) instead of being deleted.
  • Money belongs to each character: bots keep their own wallets and salary through saves, restarts and switches. Mission pay at round end goes to the character each player is driving.
  • Death effects (Permadeath, skill loss) apply to the character that died, not to whoever was driving it. In Permadeath, a player whose own character is permanently dead makes the next crew member they take their new character.
  • Disconnecting hands your body to the AI; rejoining puts you back in the character you last played (protect off) or your own character.
  • Admin chat commands: !crewswap status, !crewswap pool shared|own, !crewswap protect on|off, !crewswap owner "<character>" "<player>", !crewswap check. Command messages never show in public chat (except when CrewSwap is off because of a version mismatch).

Safety

  • Version gate: on any other game version (or with sandboxed server Lua) the mod installs nothing and the campaign runs as vanilla. A different LuaCs build only logs a warning; the patch-install audit and runtime checks below still guard it.
  • The mod switches itself off if any patch fails to install, a module fails to load, a player body spawns without AI, or the crew list or another required game API can’t be read.
  • Integrity check before and after vanilla saves the crew: on a failure the save is not written, the campaign is reloaded from the last good save, and switching and new rounds stay off until an admin runs !crewswap check.
  • Vanilla’s rolling save backups raised from 3 to 20.
  • All CrewSwap data is stored inside the vanilla save (plus a recovery mirror file next to it), so a save always loads with the mod off.

Known limitations

  • CrewSwap is off in Ironman campaigns and in non-campaign game modes.
  • RetainExperienceForNewCharacter is disabled while CrewSwap is active.
  • Player bodies are AI characters, so player attacks are not written to the server’s attack log.
  • On save-and-quit, mission pay goes to the players’ own characters, and a bot that died that round keeps the inventory saved from its corpse.
  • The camera snaps on a switch (as with vanilla bot takeover); there is no transition.
  • See Differences from vanilla for the complete list.