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,ownerandcheck, 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 server | Requirements, Client install, Playing |
| Running a dedicated server | Requirements, Server install, Admin commands |
| Curious about what changes | Differences from vanilla |
| Something went wrong | Troubleshooting, FAQ |
| A developer | Architecture 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
| Component | Version CrewSwap 0.9.0 was built for |
|---|---|
| Barotrauma | 1.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
| Mode | CrewSwap |
|---|---|
| Multiplayer campaign: respawn modes MidRound, BetweenRounds, None | Active |
| Multiplayer campaign: Permadeath (Ironman off) | Active |
| Multiplayer campaign: Ironman | Off (vanilla Ironman rules apply) |
| Sandbox, mission and PvP rounds | Off |
| Singleplayer | Not 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
| LuaCs | CrewSwap | |
|---|---|---|
| Dedicated server | Server-side LuaCs installed | In the server’s LocalMods folder and enabled (Server install) |
| Every player who wants to switch | Client-side LuaCs installed (Client install) | Downloaded automatically from the server on joining (subscribing on the Workshop is optional) |
| Players without client-side LuaCs | none | Can 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 sandboxedand 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, andfilelist.xmlmust be directly inside it. - Copy only the mod folder. Don’t put the repository’s
tests/ordocs/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 with85ded59.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 therunning on gameline. CrewSwap keeps running; see the warning signs below. If LuaCs’s build can’t be read, the revision is blank and the warning saysrunning on unknown. active: falseis 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
CrewSwapfolder inside it.
Then load or start a multiplayer campaign and start a round. You should see:
[CrewSwap] round start, active: true
Warning signs:
| Console line | Meaning |
|---|---|
[CrewSwap] OFF: version mismatch (...) and no patches installed line | Wrong 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 all | The 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
- Stop the server.
- Replace
LocalMods/CrewSwapwith the new version. Your settings live in the save folder, so you lose nothing. - 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
- Subscribe to Lua for Barotrauma on the Steam Workshop.
- 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.
- 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 failedormissing <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:
| Message | What 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 like | Meaning | Clicking it |
|---|---|---|
| Normal colours | Free for you | Switches you into that character |
| Greyed out, with a player’s name on the right | That player is playing it | Nothing from CrewSwap; the game’s usual click behaviour |
| Normal row, dimmed name | A crew member you can’t take right now | You get a chat line saying why (for example, it belongs to another player) |
| Your own current character | You | The 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
| Message | Why |
|---|---|
[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
ownis yours. - A bot nobody owns yet (the starting crew, or anything hired before
ownmode was turned on) belongs to the first player who plays it while the pool isown. - You can always take your own character.
- In
ownmode, 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
!crewswapcommand (the word!crewswapon its own or followed by a space), from anyone, so it never appears in public chat. A message like!crewswapperisn’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!crewswapmessages show in chat and get no reply. Check the server console forOFF: 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
| Command | Who | What it does |
|---|---|---|
!crewswap or !crewswap status | Everyone | Shows whether CrewSwap is active and the current settings |
!crewswap pool shared | Admin | Any Free crew member can be taken (default) |
!crewswap pool own | Admin | Players can only take their own character, crew they own, and crew nobody has claimed yet |
!crewswap protect on | Admin | Players’ own characters can only be played by them (default) |
!crewswap protect off | Admin | Players’ own characters join the pool while their owner plays someone else |
!crewswap owner "<character>" "<player>" | Admin | Gives a crew member to a player (for own mode) |
!crewswap check | Admin | Runs 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 nowin place ofactivemeans 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:
- Hiring while the pool is
own. The hirer owns the new crew member. The console logsadmin: <player> hired <character> and now owns them. Hires made while the pool wassharedare not owned. Once the pool isown, whoever plays them first claims them. - First play, for anything unowned, as above.
!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 aliworks 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:
| Reply | Meaning |
|---|---|
[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).
| Reply | Meaning |
|---|---|
[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
| Path | What it is |
|---|---|
<save folder>/CrewSwap/config.txt | The settings above |
<save folder>/CrewSwap/selftest | If this (empty) file exists, CrewSwap runs its in-game self-test at start-up (see Testing). Delete it afterwards. |
<save folder>/CrewSwap/force-integrity-fail | A 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.xml | A 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
!crewswapchat 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
controlcommand, 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.xmlrecovery file next to the save.
Chat
!crewswapcommands (the word!crewswapon 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 see | Go to |
|---|---|
CrewSwap is off until it's updated for this game version | Version mismatch |
CrewSwap is off until an admin restarts the server | Switched itself off |
Integrity check failed (...), Integrity check failed: ... or The round can't start: an integrity check failed | Integrity 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 nothing | Clicks 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:
| Reason | Meaning |
|---|---|
game 1.14.0.0 (for example) | The server runs a different Barotrauma version than this CrewSwap release was built for. |
server Lua is sandboxed | C# 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>(ornon-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 accessibleorImmutableHashSet`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:
- Read the
FAILline. - 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/Nand noFAILlines. - 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>)
| Problem | Meaning |
|---|---|
duplicate | The same crew member is stored more than once. |
missing | A crew member from the start of the round is gone, without a permanent death or firing to explain it. |
moved | A bot ended up stored as a player character, or the other way round. |
unknown | CrewSwap 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
- An admin types
!crewswap checkin chat. - If it says
[CrewSwap] The integrity check passed., the lockdown is lifted. Start the round from the lobby as usual. - 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 checkagain, then start the round.
- 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 sayactive. - 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 buildandhello 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 (
sharedorown) and home protection (on or off).
Anti-goals
These are failures even if switching “works”:
- 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.
- Duplicating a character. No character may be saved twice or spawn twice.
- 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.
- 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_GAMEandEXPECT_LUACSinshared/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>, noRunUnrestricted="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 hasHook.Patch,Networkingand every auto-registeredBarotrauma.*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
CrewManagerroster (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
TryTakeOverBotdiscards 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
| File | Purpose |
|---|---|
filelist.xml | The 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.xml | Two 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.xml | An 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 }.
BUILDis 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.MODVERSIONis written into the save’s<CrewSwap>element.EXPECT_GAME('1.13.4') andEXPECT_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 equalsEXPECT_GAMEor starts withEXPECT_GAME .. '.'(so1.13.4.2matches but1.13.40does not); a game mismatch returnsfalse, '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 returnstrue, nil, warning, wherewarningis 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 returnstrue, 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;ownpool 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 thetakeoverdenial, which is not avalidatereason: theTAKEOVERBOTrouting inserver/death.luasends 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 withbySession(CharacterInfo.ID→ sid) andchars(sid → record).R:bind(sessionId, sid),R:sidOf(sessionId),R:record(sid)(creates on demand),R:prune(liveSessionIds)(drops stale bindings, keeps records).FIELDSmaps record fields to XML attributes:last,owner,home,wbal,wrd,lastAt→crewswap_last,crewswap_owner,crewswap_home,crewswap_wbal,crewswap_wrd,crewswap_lastat.NUMERICmarkswbal,wrdandlastAt.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’screwswap_*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 haslast == keyand the greatestlastAt.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)andrejoinTarget(...): 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 recordsstalesids (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>.legitlists removals made on purpose this round (permadead,fired,discarded) andadoptedbots 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 flagskeep,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 vanillaSavePlayersitself 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;!crewswapperis not a match.configEncode(t)/configDecode(s): thepool=.../protect=on|offtext format; unknown lines and bad values fall back to defaults.statusText(t, info),FAIL_NOTICE, andfindByName(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 (setslockdown, recordsfailReason, prints[CrewSwap] FAIL: <reason>, announcesFAIL_NOTICEnow and every round start).- The version gate (
checkVersions, see Safety). On a game-version mismatch (or sandboxed server Lua) it registers a singleroundStartchat 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): computesCS.campaignOkandCS.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), printspatches 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()(theMultiPlayerCampaignor 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)andG.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 inCrewManager:GetCharacterInfos(true)(bots and the bench). Stable ids are only minted whileCS.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): setshasAi = truefor remote-player spawns with an info, in a campaign. S.isAIBody(ch): anAICharacterwith aHumanAIController.- Audits on
roundStartandcharacter.created: a player body without AI in a multiplayer campaign is aCS.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, theCharacterInfoXML constructor,MultiPlayerCampaign.LoadandMultiPlayerCampaign.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, thenSW.switch; on denialSW.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-clientcrewswap.state.CS.isVoluntarySpectator(c): a lobby spectator, as opposed to one a permadeath kill set.- The
crewswap.helloandcrewswap.requestreceivers, the hello watchdog, and the “no client-side LuaCs” notice. See Networking.
server/presence.lua
client.disconnected(a prefix on vanilla’s disconnect, soclient.Characteris still set): record the client as last controller, thenSW.releaseso the body reverts to AI instead of freezing.- Postfix on
GameServer.OnInitializationCompletequeues a joining client; athinkpoll (every 0.5 s, for up toJOIN_WINDOW = 180s) places them withplacement.rejoinTargetonce they are in game and synced and the round is not ending. roundStart:placement.roundStartmoves 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.SaveGameandCrewManager.InitRound. CS.onSavePlayersPre/CS.onSavePlayersPost: hook lists other modules append to; any error in them blocks the save. Becauseroundendloads beforesafety, 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 athinksafety net that gives the driver back if vanilla threw between them. - Undriven permadeath for a home the AI was running.
RespawnManager.RespawnCharactersprefix: let go of a dead borrowed corpse before respawn.RetainExperienceForNewCharacteroff (SaveExperiencePointsskipped,GetSavedExperiencePointsreturns 0).TAKEOVERBOTrouting (GameServer.ReadTakeOverBotMessage) and theClient.TryTakeOverBotpostfix.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.MaxBackupCountto 20. - Patches on
Character.KillProjSpecific,CrewManager.FireCharacter,MultiPlayerCampaign.DiscardClientCharacterData,SaveUtil.SaveGame,GameServer.EndGame,MultiPlayerCampaign.LoadCampaign(prefix and postfix) andGameServer.TryStartGame.
server/admin.lua (A)
A.load()/A.save():config.txtin<SaveFolder>/CrewSwap/, written via a.tmpfile and a move.A.onChat(text, client)on thechatMessagehook: parses withcommands.parse, runs the command, and returnstrueso the message never shows in public chat (for every sender).A.run(client, cmd):statusfor everyone;pool,protect,ownerandcheckfor admins.- Postfix on
CampaignMode.TryHireCharacter: inownpool 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 onCrewManager.CharacterClicked. Sends a switch request for a Free row and suppresses vanilla’s click; see Networking.CSC.onRowAdded(cm, p)(postfix onCrewManager.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 whoseIsPlayerflag 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 throughGame.Client.Character, thenCSC.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 setsCSC.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:
- Prints
[CrewSwap] server loaded build <BUILD>. - Creates the global
CStable. 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. - Defines
CS.failand aroundStarthook that repeats the failure notice while one is recorded. - Version gate. Requires
CSActive == true(unsandboxed server Lua), registersSystem.VersionandAssemblyInfo, readsBarotrauma.GameMain.VersionandAssemblyInfo.GitRevision, prints[CrewSwap] running on game <v>, LuaCs <rev>, and compares them withcore/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. - Defines
CS.recomputeActiveand aroundStarthook that recomputes it. - Loads the server modules in dependency order. Each module installs its patches and hooks at load time and registers its selftest checks.
- Prints
modules loaded: ...andpatches installed: N/N(36 on the current build), then computesCS.active. - If
<SaveFolder>/CrewSwap/selftestexists, 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
CharacterInfofrom XML, the postfix on theCharacterInfo(ContentXElement, Identifier)constructor readscrewswap_idand thecrewswap_*record fields intoCS.pending[info.ID]. - The
MultiPlayerCampaign.Loadpostfix reads<CrewSwap campaignguid>, builds a fresh registry, binds every live crew info to its pending sid, records duplicate sids inCS.dupes, and fills any gaps from the mirror. It logsidentity: load bound N of M crew, campaign <guid>. - The
MultiPlayerCampaign.LoadCampaignpostfix (insafety.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.Createprefix (spawn.lua): for a remote-player spawn with an info in a campaign,p['hasAi'] = true. Every campaign crew body is therefore anAICharacter.CrewManager.InitRoundpostfix and thecharacter.createdhook (roundend.lua): each roster bot’s wallet balance and salary are restored from its record.character.createdcovers bots created inline (the first round of a campaign) and bench spawns, which never pass throughInitRound.
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.luaclears 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 inlastand the latestlastAt), 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
SetClientCharactercalls. Vanilla sendsControlEventData(null)for the body being left only when a client is set tonil; 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()andAIController:Reset()on the old body make it pause briefly and then pick up its orders again, the way singleplayer does.- Never
TryTakeOverBot, neverRenamingEnabled. 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.validatechecks, 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 (nohomeotherwise), 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
SpectateOnlyon the driver in vanilla, and vanilla’s ownership loop would kill the new body at once.SW.switchclears 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.
SetClientCharacterpointsclient.CharacterInfoat the target, and only a CCD ever points it back, so switching now would lose their character. Bothrules.validateandSW.switchrefuse. - Own-pool claim. In
ownmode, 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,
adoptHomemakes it their new home:SetClientCharacterDatabuilds a CCD from the target’s info, the info is removed from theCrewManagerroster (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:
- Prefix (
crewswap.kill.pre): if the body is crew and driven, remember{ driver, body, cause, balance }inCS.deathCtx[ch.ID]and setdriver.Character = nil, which hides the driver from all three of vanilla’s lookups. - Vanilla kills the body with nobody to blame.
- 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.CharacterInfoat 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
ApplyPermadeathon that CCD and pay the body’s wallet into the campaign bank, becauseApplyPermadeathdrops the CCD wallet; a bot just getsPermanentlyDead = true; then bump theCharacterInfonet flag; - send the traitor notice to the driver if a traitor did it;
- broadcast state so the dead player sees which crew are Free.
- re-point
- A
thinkhook restores the driver if vanilla threw insideKillProjSpecificand 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.RespawnCharactersdespawns 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 onGameServer.ReadTakeOverBotMessagepeeks 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 withclient.CharacterInfopointed 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 onClient.TryTakeOverBotrecords the result as the player’s new home. - RetainExperienceForNewCharacter is off while active: the prefix on
MultiPlayerCampaign.SaveExperiencePointsskips it andGetSavedExperiencePointsreturns 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.disconnectedfires as a prefix on vanilla’s disconnect, whileclient.Characteris 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 afterKillDisconnectedTime. It also stops “disconnect to dodge death”. - Rejoin. A postfix on
GameServer.OnInitializationCompletequeues the client (LuaCs’sclient.connectedreports the last connected client, which can be the wrong one when two join together). Athinkpoll waits until the client is in game, synced and the round is not ending, then appliesplacement.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:
| Step | Patch | What CrewSwap does |
|---|---|---|
GameSession.EndRound | prefix crewswap.endround | CS.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, talents | postfix crewswap.crewset on GameSession.GetSessionCrewCharacters | Add 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.End | prefix/postfix crewswap.endguard | Raise the keep-dead guard and stage a snapshot of every bot wallet. |
(inside End) | prefix crewswap.keepdead on CrewManager.RemoveCharacterInfo | While the guard is up, skip vanilla’s removal of a dead non-home crew member and record it in CS.keptDead. |
MultiPlayerCampaign.End | postfix crewswap.gameover | Correct GameOver on save-and-quit (below). |
SavePlayers | prefix crewswap.restore | Restore (below), then the pre-save hooks: keep-dead decisions for the bodies SavePlayers will drop, and the pre-save integrity check. |
SavePlayers | postfix crewswap.restore.post | Put kept crew back in the roster, revive them, run the post-save integrity check. |
MultiPlayerCampaign.Save | prefix/postfix | Mint ids for new crew, write <CrewSwap>, write the mirror. |
CharacterInfo.Save | postfix | Write crewswap_* attributes on each <Character>. |
SaveUtil.SaveGame | prefixes | Commit staged wallets into records; block the write if a check failed. |
GameServer.EndGame | postfixes | Revive 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:
- 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, becauseSavePlayerssnapshots items only for bots and then deletes every inventory); a dead or removed one is just let go. - A living home goes back to its owner with
SetClientCharacter(releasing whoever was driving it first). - 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 fromclient.Character. - 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 callsCrewManager.RemoveCharacterInfo, which thecrewswap.keepdeadprefix can skip.SavePlayersremoves dead bots too, but no patch onRemoveCharacterInfofires for that call site at runtime on a dedicated server (the tiny method is inlined intoSavePlayers’ patched replacement). So theSavePlayersprefix decides up front, usingrevive.dropsAtSave, which bodies vanilla will drop, and the postfix puts any kept info vanilla removed back withCrewManager.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.
SavePlayersruns beforeMultiPlayerCampaign.End, whileGameSession.IsRunningis still true.EndsetsGameOverfrom “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 recordsCS.restoredBeforeEndand whether anyone was alive before restoring, and theMultiPlayerCampaign.Endpostfix puts back that pre-restore value. It never recomputesGameOverfrom 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.Enddespawns 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)
| Data | Where vanilla keeps it |
|---|---|
| A player’s home character | That 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 member | The CrewManager roster (active crew plus the reserve bench), saved inside the campaign .save. |
| Rolling backups | Vanilla’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(inserver/switch.lua) creates their new CCD from that info withMultiPlayerCampaign.SetClientCharacterDataand 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:
| Field | XML attribute | Meaning |
|---|---|---|
last | crewswap_last | Account key of the last player who drove this character. |
lastAt | crewswap_lastat | os.time() of that switch, in seconds. A player’s “last played” character is the record with their key in last and the greatest lastAt. |
owner | crewswap_owner | Account key of the owner in own pool mode (the hirer, the first player to take an unclaimed character, or whoever an admin assigned). |
home | crewswap_home | Account 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. |
wbal | crewswap_wbal | Bot wallet balance. |
wrd | crewswap_wrd | Bot 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.Saveprefix recomputesCS.active. If the campaign object is one it never saw load (CampaignIDdiffers), 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, logsidentity: save: minted N new ids for M crew, and prunes stale session bindings. After a restart or reload, existing crew must showminted 0. - During a lockdown (see Safety) every id and record the mod already holds is still
written, because
CS.campaignOkignores lockdowns. Only minting waits forCS.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.luasees a campaign object it has not seen and callsbindMissing(infos, false): it re-matches the live infos to mirror rows withcore/mirror.matchand logsidentity: 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:
- Stage. In the
CampaignMode.Endprefix,CS.snapshotWallets()copies each bot’sWallet.BalanceandWallet.RewardDistributionintoCS.walletStage, keyed by sid. If any body cannot be read, the save is blocked rather than written with a stale wallet. - Commit. In the
SaveUtil.SaveGameprefix 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 beforeCampaignMode.Endruns, so there is no stage yet: the commit’s own re-snapshot is what writes the wallets, and the stage taken afterwards atEndis dropped by theGameServer.EndGamepostfix. - Discard. On an unsaved end, the
GameServer.EndGamepostfix drops the stage. - Restore. In the
CrewManager.InitRoundpostfix and oncharacter.created, each roster bot with a storedwbalgetsBalanceset,ForceUpdate()called, andSetRewardDistribution(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
| State | Lifetime |
|---|---|
| sids, records (owner, last played, wallets) | Per campaign, in the save. |
| Campaign GUID | Per campaign, in the save. |
pool, protect | Server-wide, config.txt. |
| Who is driving whom | Per round, in memory only. The restore puts everyone home before every save. |
| Kept dead, staged wallets, integrity snapshot | In 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 cooldowns | In 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:
- Version gate: wrong game version (or sandboxed server Lua), no patches at all; a different LuaCs commit only logs a warning.
- Patch-install audit: any patch that fails to resolve switches the mod off.
- Runtime audits: wrong-shaped bodies or an unreadable crew list switch the mod off.
- Every callback is guarded: errors are caught, logged and, on save paths, block the save.
- 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. - 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 registeringSystem.Version) andAssemblyInfo.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 anAICharacterwith aHumanAIControlleris aCS.fail. ThehasAiprefix onCharacter.Createcould 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()convertsCrewManager:GetCharacterInfos(true)and checks every element is aCharacterInfo. 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
SavePlayersrestore; - any
onSavePlayersPre/onSavePlayersPosthook (keep-dead decisions, revival, integrity checks); - a bot wallet that cannot be read at
CampaignMode.Endor 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 withMakeFieldAccessible);roster: everyCrewManagerinfo, 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:
| Problem | Meaning |
|---|---|
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:
| Kind | Noted by |
|---|---|
permadead | A crew death in Permadeath (postfix on Character.KillProjSpecific), or a home already permanently dead at the snapshot (it keeps its CCD until replaced). |
fired | Prefix on CrewManager.FireCharacter. |
discarded | Prefix on MultiPlayerCampaign.DiscardClientCharacterData (vanilla “create new character”, and the CCD drop inside TryTakeOverBot). |
adopted | Permadeath 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 theSavePlayersprefix (only whileCS.active, so it is skipped during a lockdown even though the restore still runs);post-save: after vanilla’sSavePlayers(and after kept crew are put back), also only whileCS.active;manual: when an admin types!crewswap check;selftest: the in-game selftest’ssafety-runcheckcheck.
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
- Don’t write. The
SaveUtil.SaveGameprefix setsPreventExecution, so nothing reaches the disk. The save-on-load (isSavingOnLoading) is never blocked: it only rewrites what was just loaded. - Reload. Memory already holds what
SavePlayersdid (folded CCDs, deleted inventories), so continuing from memory is not an option. TheGameServer.EndGamepostfix reloads the last good save the way vanilla’s failed-round branch does, then bumps every net update id so clients resync. - Lock before reloading. On the server,
LoadCampaignstarts the next round itself before it returns. The lock is therefore set before the reload, or it would never reach the lobby refusal. - Hold the lobby. While an integrity lock is in force, a prefix on
GameServer.TryStartGamerefuses to start a round (it returnsTryStartGameResult.GameAlreadyStartedand announcesThe round can't start: an integrity check failed. An admin must run !crewswap check.). Switching is off too (CS.activeis false). - Load-time duplicates. If a save being loaded already holds one sid on two crew members
(
CS.dupes, found by theMultiPlayerCampaign.Loadpostfix), theLoadCampaignpostfix locks down at once with its own alarm, andTryStartGameis 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
| Symptom | Cause | What lifts it |
|---|---|---|
OFF: version mismatch | The game version differs from the build, server Lua is sandboxed, or the game version couldn’t be read | A CrewSwap release for that version, or LuaCs’s default C# run policy. |
WARNING: built for LuaCs ... | The LuaCs commit differs from the one tested | Nothing needed; the mod keeps running. Put back the tested LuaCs build if something misbehaves. |
FAIL: <reason> | Patch, module, audit or live-list failure | Fix 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 id | Load 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
| Id | Target | When | Why |
|---|---|---|---|
crewswap.forceai | Barotrauma.Character.Create (the 10-parameter overload: CharacterPrefab, Vector2, String, CharacterInfo, UInt16, Boolean, Boolean, Boolean, RagdollParams, Boolean) | Before | Set hasAi = true for campaign remote-player spawns, so every crew body is an AICharacter that can be handed back to the AI. |
crewswap.ci.save | Barotrauma.CharacterInfo.Save(XElement) | After | Add crewswap_id and the record’s attributes to the <Character> element vanilla returns. |
crewswap.ci.ctorxml | Barotrauma.CharacterInfo..ctor(ContentXElement, Identifier) | After | Read them back when an info is built from XML (into CS.pending). |
crewswap.mpc.load | Barotrauma.MultiPlayerCampaign.Load(XElement) | After | Read <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.pre | Barotrauma.MultiPlayerCampaign.Save(XElement, Boolean) | Before | Returns 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.post | same | After | Write <CrewSwap> into the MultiPlayerCampaign element; write the mirror file. |
Switching, joining and the crew-down window
| Id | Target | When | Why |
|---|---|---|---|
crewswap.join | Barotrauma.Networking.GameServer.OnInitializationComplete(NetworkConnection, String) | After | Reliable “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.pre | Barotrauma.Networking.GameServer.Update(Single) | Before | While the grace window is open, set UsingFreeCam on clients so vanilla’s crew-down test sees someone up. |
crewswap.crewdown.post | same | After | Clear the flags again (a think hook does too, in case Update threw). |
Round end, keep-dead and wallets
| Id | Target | When | Why |
|---|---|---|---|
crewswap.endround | Barotrauma.GameSession.EndRound | Before | Mark the round as ending; snapshot each client’s home body before CampaignMode.End despawns the dead. |
crewswap.crewset | Barotrauma.GameSession.GetSessionCrewCharacters | After | Add released home bodies to the crew set when bots are included, so idle homes get mission XP and round-end talents like bots. |
crewswap.endguard | Barotrauma.CampaignMode.End(CampaignMode+TransitionType) | Before | Raise the keep-dead guard; stage bot wallets. |
crewswap.endguard.post | same | After | Lower the guard. |
crewswap.keepdead | Barotrauma.CrewManager.RemoveCharacterInfo(CharacterInfo) | Before | While the guard is up, skip the removal of a dead non-home crew member (PreventExecution). |
crewswap.gameover | Barotrauma.MultiPlayerCampaign.End(CampaignMode+TransitionType) | After | On save-and-quit, put back the pre-restore GameOver value. |
crewswap.restore | Barotrauma.MultiPlayerCampaign.SavePlayers | Before | The restore, then keep-dead decisions and the pre-save integrity check. |
crewswap.restore.post | same | After | Put kept crew back, revive them, post-save integrity check. |
crewswap.endgame | Barotrauma.Networking.GameServer.EndGame | After | On an unsaved end, revive kept crew without a penalty and drop staged wallets. |
crewswap.wallets.save | Barotrauma.SaveUtil.SaveGame(CampaignDataPath, Boolean) | Before | Commit staged wallets into records for a save that is really written. |
crewswap.wallets.init | Barotrauma.CrewManager.InitRound | After | Restore bot wallets at round start. |
Death
| Id | Target | When | Why |
|---|---|---|---|
crewswap.kill.pre | Barotrauma.Character.KillProjSpecific | Before | Hide 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.post | same | After | Give the driver back and apply the effects to the character that died; undriven permadeath. |
crewswap.respawn.detach | Barotrauma.Networking.RespawnManager.RespawnCharacters(RespawnManager+TeamSpecificState) | Before | Outside Permadeath, let go of a dead borrowed corpse so respawn does not despawn it. |
crewswap.retainxp.save | Barotrauma.MultiPlayerCampaign.SaveExperiencePoints | Before | Skip it: RetainExperience is keyed per client, which switching would turn into free XP. |
crewswap.retainxp.get | Barotrauma.MultiPlayerCampaign.GetSavedExperiencePoints | Before | Return 0 (an int method: the number is the Lua return value). |
crewswap.takeover | Barotrauma.Networking.GameServer.ReadTakeOverBotMessage | Before | Route vanilla TAKEOVERBOT through the CrewSwap switch; let hire/bench ids through only where they cannot discard a living home. |
crewswap.takeover.post | Barotrauma.Networking.Client.TryTakeOverBot | After | When vanilla did take a bot over, record it as the player’s new home. |
Safety
| Id | Target | When | Why |
|---|---|---|---|
crewswap.safety.kill | Barotrauma.Character.KillProjSpecific | After | Note a Permadeath crew death as a legitimate removal. |
crewswap.safety.fire | Barotrauma.CrewManager.FireCharacter(CharacterInfo) | Before | Note a firing as legitimate. |
crewswap.safety.discard | Barotrauma.MultiPlayerCampaign.DiscardClientCharacterData | Before | Note vanilla’s CCD discard (“create new character”, takeovers) as legitimate. |
crewswap.safety.save | Barotrauma.SaveUtil.SaveGame(CampaignDataPath, Boolean) | Before | Prevent the disk write when a check failed. |
crewswap.safety.revert | Barotrauma.Networking.GameServer.EndGame | After | After a blocked write: lock down, reload the last good save. |
crewswap.safety.load.pre | Barotrauma.MultiPlayerCampaign.LoadCampaign(CampaignDataPath, Client) | Before | Lift an integrity lock for the duration of a load so save-on-load writes the ids. |
crewswap.safety.load | same | After | Put the lock back; take a new baseline; lock on duplicate ids in the loaded save. |
crewswap.safety.lobby | Barotrauma.Networking.GameServer.TryStartGame | Before | Refuse 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
| Id | Target | When | Why |
|---|---|---|---|
crewswap.hire.owner | Barotrauma.CampaignMode.TryHireCharacter | After | In 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):
| Id | Target | When | Why |
|---|---|---|---|
crewswap.click | Barotrauma.CrewManager.CharacterClicked | Before | A 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.row | Barotrauma.CrewManager.AddCharacterToCrewList | After | Remember 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)
| Event | Used for |
|---|---|
roundStart | Active recompute, notices, spawn audit, live-list check, placement, state broadcast, integrity snapshot, per-round resets (server); control tracker reset (client). |
roundEnd | Client only: forget crew-list rows. Not used on the server, because LuaCs also fires it on GameSession.LoadPreviousSave. |
think | Join 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.disconnected | Release the body; forget the client’s hello state. |
character.created | Respawn AI audit; bot wallet restore for bodies created outside InitRound. |
character.death | Broadcast 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.RegisterTypeforSystem.Version,AssemblyInfo,System.Guid,Barotrauma.MultiPlayerCampaign,Barotrauma.SaveUtil,Barotrauma.IO.Directory,System.Collections.Immutable.ImmutableHashSet`1(the open generic; closed types inherit it) andBarotrauma.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.WalletandBarotrauma.Networking.IReadMessageto probe their members. - Enum tables come from
LuaUserData.CreateEnumTableforBarotrauma.MultiPlayerCampaign+NetFlags,Barotrauma.Networking.RespawnMode,GameServer+TryStartGameResultandBarotrauma.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.getreturns0to replace anint;crewswap.safety.lobbyreturnsTryStartGameResult.GameAlreadyStarted;- the client’s
crewswap.clickreturnstrueafter settingPreventExecution.
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.Patchtime.P.patchcatches it and turns it intoCS.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 orp.OriginalReturnValuerather 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. requireis shared by every mod, hence thecrewswap.prefix on every module.- Sandboxed client Lua can still
Hook.Patchany game type, but can only registerBarotrauma.*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.
| Name | Direction | Payload | Meaning |
|---|---|---|---|
crewswap.hello | client → server | String 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.state | server → client | UInt16 count, then per crew member: UInt16 entity id, String controller name ('' if none), Boolean free | The crew as this client should see it. |
crewswap.request | client → server | UInt16 entity id | “I want to drive this character.” |
crewswap.denied | server → client | String reason, Single extra | A 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 = 5000ms, at mostHELLO_TRIES = 12times, until anycrewswap.stateanswers. - 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
thinkwatchdog sendscrewswap.hello?to any in-game client it has no hello from, everyHELLO_ASK_EVERY = 5s, at mostHELLO_ASKS = 12times; the client answers with a fresh hello. The hello tables also survive a server Lua reload (server/main.luacopies 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 = 90s 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
BUILDdiffers 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 asinactive. - Rejoining starts over.
client.disconnectedforgets 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
ownmode, 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.
CameraTransitionrestores its previous character when it ends, which would undo the switch and desync input. The client checksCoroutineManager.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:
- 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.
- 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.
- 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)(expectsfnto 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 witho.F.requester(o): an eligible switch requester.F.buffer(): a network message double withWrite*/Read*forUInt16,String,BooleanandSingle. 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.loadedand re-require it, so each test starts clean; - install fakes that record what the module did (
Hook.AddandHook.Patchcapture the callbacks,SetClientCharacterrecords calls,Networking.Sendrecords 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).
| Module | Checks |
|---|---|
selftest | timing (Timing.TotalTime is a number), enum-numbers (CauseOfDeathType.Disconnected is a number) |
game | list-convert (G.list on Character.CharacterList and a plain table) |
spawn | create-sig-resolves |
identity | ci-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) |
switch | state-encode, ccd-api (SetClientCharacterData, GetClientCharacterData, RemoveCharacterInfo exist), crew-views |
presence | join-patch-resolves |
roundend | roundend-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) |
death | death-patches, death-api (ApplyPermadeath, Bank, IncrementLastUpdateIdForFlag, wallet and IReadMessage members) |
safety | safety-patches, safety-backups (MaxBackupCount is 20), safety-api, safety-runcheck |
admin | config-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: truein a campaign round- no
[CrewSwap] FAILlines and no... error:lines identity: save: minted 0 new ids for N crewon 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.
| Area | What is checked |
|---|---|
| Install and handshake | The 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. |
| Activity | Active 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. |
| Switching | Click 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. |
| Presence | Disconnect 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 end | Level transition and save-and-quit with players in borrowed bodies: everyone stored once, items and skills kept; idle homes get mission XP. |
| Dead crew | A dead bot comes back next round with the skill penalty (transition) or without (unsaved end); items in a duffel bag after a transition. |
| Wallets | A bot’s money survives a transition and a restart; an unsaved end reverts it with the player characters’. |
| Death | Death 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 down | The 120 s grace window with its three messages; switching in cancels it; nobody switching ends the round as vanilla. |
| Safety | The 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. |
| Admin | Commands 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. |
| Restarts | A 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.2on Debian/Ubuntu; most package managers have alua5.2orlua@5.2package). 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 servefrom 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
modversionwould keep running old scripts. - The server and every client must run copies of the same package. The client’s hello carries
BUILDand 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
- Stop the server.
- Unzip
dist/CrewSwap-<version>.zipinto the server’sLocalMods/folder (replacing any olderCrewSwap/). - Make sure the package is enabled for the server (see Server installation).
- Start the server and check the console for
[CrewSwap] server loaded build <BUILD>,patches installed: 36/36(or the current total) and noFAILlines. - 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.ymlruns on pushes tomainthat touch code (docs-only and prose-only changes are skipped), on every pull request and on manual dispatch: the Lua 5.2 unit tests, atools/package.sh 0.0.0 ci-1package smoke test and an XML well-formedness check of the mod.docs.ymlruns whendocs/,book.toml,theme/orlanding/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 tomain.release.ymlruns when av*tag is pushed: for example, pushingv0.9.0-betaruns the tests, runstools/package.sh 0.9.0 betaand attachesCrewSwap-0.9.0.zipto a GitHub release. A tag with a-suffixbecomes theBUILDsuffix 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. Keepserver/andclient/modules as thin adapters that read the game, call core, and apply the result. - Namespace every module
crewswap.<area>.<name>: therequirecache is shared by every mod. - Never write runtime files inside
mod/CrewSwap/: that folder ships to every client. Server data goes underGame.SaveFolder .. '/CrewSwap/'or next to the campaign save. - Pure, sandboxed Lua only: no
<Assembly>, noRunUnrestricted="true". Client code must work both sandboxed and unsandboxed.
Patches
- Install every server patch with
P.patch(...), never a bareHook.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.activegates on the restore, safety and load paths as they are (see Patches). Patches are never removed. - Wrap every callback body in
pcall(the existingsafe/guardhelpers) and end with a barereturn. 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 exampleFAIL:,OFF:,WARNING:,INTEGRITY ...,SELFTEST ..., the grace countdown, the boot lines (server loaded build,running on game,save folder:,modules loaded:,patches installed:,active:) andround start, active: .... Every console line starts with[CrewSwap]. - Keep the denial wording in
core/rules.luaso the server and client stay in sync.
Style
- Two-space indentation,
localeverything, 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:
- Read the game and LuaCs changelogs.
- 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 whatSavePlayersremoves). - Update
EXPECT_GAMEand/orEXPECT_LUACSinmod/CrewSwap/Lua/crewswap/shared/build.lua. - 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.luagreen. - Update the relevant pages in
docs/and add an entry to both changelogs: the rootCHANGELOG.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,Boboryour-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) orown(you can take your own character, crew you hired while the pool wasown, 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.
- Crew pool:
- 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.
RetainExperienceForNewCharacteris 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.