Myself, I'd recommend keeping the server and client authored in a single Unity project. This minimizes the chance for mistakes where you change something in either the server or client and miss updating the other, resulting in divergent behaviour or major errors.
You could approach it in other ways, like bundling everything needed by both client and server into a shared library and asset pack referenced in each separate project, but that's extra hoops to jump through every time you change something.
Instead, we can keep everything in one project, and use preprocessor directives to control what gets built into the client versus server. Code wrapped in an #if SERVER_BUILD ... #endif
pair will be compiled only if you've defined the SERVER_BUILD
symbol in this compilation pass.
We can add our SERVER_BUILD
symbol under Build > Player Settings... > Other Settings > Configuration> Scripting Define Symbols when we're exporting a server build, or leave it out when compiling for the client.

You can automate this with a custom build script or command line shortcut, so you can export either a client or server build on demand without manually twiddling the settings every time.
To avoid littering your code with #if
directives all over the place, you can take advantage of a feature in C# called partial classes. This lets you split the definition of a single class between multiple files, and we'll use it to group together the common, client, and server-specific functionality we need.
First we'll define a partial class that has just the stuff that's always needed - no matter whether we're a server build or a client. Here's a very barebones example:
public partial class PlayerController : MonoBehaviour
{
// Common variables needed on both server and client go here.
public float movementSpeed;
void Start() {
// Common start-up code needed on both server and client
// can be written here.
// Call serve/client-specific start-up behaviour,
// depending on which one we are.
OnStartup();
}
void Move(Vector2 input) {
// TODO: implement move logic.
// Note that here, we don't know whether the input
// came from a locally-connected gamepad/keyboard,
// or a network packet. We can handle it agnostically.
}
}
Note that this class has no notion of the source of input, or that the player has a model/sprite/animator at all, since only the client needs to worry about that stuff.
Then we can have a separate file, call it PlayerController_client.cs
that expands on this with client-specific needs and implementations. We wrap the whole thing inside an #if !SERVER_BUILD
directive, so when we're compiling a server build, the compiler sees this as an empty file and it has no effect on the code that's generated or the data that's packed.
#if !SERVER_BUILD
using UnityEngine;
public partial class PlayerController : MonoBehaviour
{
public Animator visualPrefab;
public string horizontalAxis;
public string verticalAxis;
Animator _visual;
Camera _camera;
void OnStartup() {
_visual = Instantiate(visualPrefab, transform);
_camera = Camera.main;
}
private void Update() {
// Capture input from client's controller.
Vector2 input = new Vector2(Input.GetAxis(horizontalAxis),
Input.GetAxis(verticalAxis));
// TODO: SendInputToServer(input);
// Locally predict the movement based on this input.
Move(input);
// Update our animation states accordingly.
_visual.SetFloat("horizontal", input.x);
_visual.SetFloat("vertical", input.y);
// TODO: Handle applying corrections from server in case of bad predictions.
}
}
#endif
Here the visual representation of the player character isn't part of the character prefab. We spawn it from its own prefab in Start. (You could also do this in an editor callback if you need to position player avatars visually in the scene when setting up maps)
Because this reference to the visual prefab is only present in the client version, when we compile a server version the visual model is completely unreferenced, and Unity can optimize your build by excluding that model, animation data, materials, textures, etc. from the server version of the game.
Note that we still call into the same Move()
function that's shared between the server and client functions. So any changes we make to that movement logic will take effect identically between server and client.
We can make another partial class for the server-specific functionality. In that version, our Update
method will take its input from the network messages received from the client, instead of trying to read a local gamepad. But again we'll call into the same Move()
function to get consistent simulation rules. And we'll skip the overhead of updating the character's animation state, since the server doesn't care about that detail (as long as you're not using animation events to affect gameplay beheviour - if you do that, then you'd want to move the animation logic into the shared file).
This basic strategy will let you very efficiently strip-out functionality or assets you don't need for one platform or another at compile time, and switch between implementations appropriate for each. Doing it in separate files through partial classes helps you keep the code organized, and clearly separate responsibilities - one member of your team could be updating the server logic while another one edits the client-side feedback, without versioning conflicts.