Setting Up Monorepo for a Unity Project

In a Client-Server architecture, the client and the server need to share the api codebase for consistency. Some solutions to do so are as follows:

  • Git submodule: Have three repositories (client, server, api). The client repo adds the api repo as a submodule. Do the same for the server.
  • Monorepo: Have one repository (say MyGame). Create three directories at the root: /Client, /Server and /Api, each containing its corresponding codes.
  • Symlink: Create a Symlink to the API for the client and the server to reference.
  • …and many more.

I prefer monorepo for simplicity, but setting one up with Unity wasn’t simple because it can’t easily reference external .csproj and its binaries.

This post explains how to set up a monorepo with a .NET server and a Unity client sharing one .csproj.

Job 1: Set up Directory Structure

Step 1: Set up directories on CLI

Create a directory. This will be the root.

mkdir MyGame

Enter it.

cd MyGame

Create a server project.

dotnet new console -o MyGame.Server

Create an API one, too.

dotnet new classlib -o MyGame.Api

Now the structure should look as below.

MyGame
├── MyGame.Api
│   ├── Class1.cs
│   ├── MyGame.Api.csproj
│   └── obj
│       ├── MyGame.Api.csproj.nuget.dgspec.json
│       ├── MyGame.Api.csproj.nuget.g.props
│       ├── MyGame.Api.csproj.nuget.g.targets
│       ├── project.assets.json
│       └── project.nuget.cache
└── MyGame.Server
    ├── MyGame.Server.csproj
    ├── obj
    │   ├── MyGame.Server.csproj.nuget.dgspec.json
    │   ├── MyGame.Server.csproj.nuget.g.props
    │   ├── MyGame.Server.csproj.nuget.g.targets
    │   ├── project.assets.json
    │   └── project.nuget.cache
    └── Program.cs

Step 2: Set up a Unity project

In Unity Hub, navigate to Projects - New Project to create a project. Set the project name as desired. Set its location to point to the root. Check out the screenshot below:

Screenshot

Click on Create Project.

Now the structure should look as below.

MyGame
├── MyGame.Api
│   ├── Class1.cs
│   ├── MyGame.Api.csproj
│   └── obj
├── MyGame.Server
│   ├── MyGame.Server.csproj
│   ├── obj
│   └── Program.cs
└── MyGame.Unity
    ├── Assembly-CSharp-Editor.csproj
    ├── Assembly-CSharp.csproj
    ├── Assets
    ├── Library
    ├── Logs
    ├── MyGame.Unity.sln
    ├── Packages
    ├── ProjectSettings
    ├── Temp
    └── UserSettings

Job 2: Set up a Solution

Step 0: Why bother to make a solution

Typical .NET projects have the following structure,

MySolution
├── ProjectA
│   ├── ProjectA.csproj
│   ├── src
├── ProjectB
│   ├── ProjectB.csproj
│   ├── src
├── ProjectC
│   ├── ProjectC.csproj
│   ├── src
├── MySolution.sln

where MySolution.sln, a solution file, references the three .csproj files.

We need a solution because Visual Studio and JetBrains Rider need one to see the projects in one view. With no solution, you have to open an editor window per .csproj. That’s inconvenient. To view all projects in one window is the point of monorepo.

Step 1: Initialize Root Solution

At the root directory, create a solution file.

dotnet new sln

Include the server project.

dotnet sln add MyGame.Server

Also for the API.

dotnet sln add MyGame.Api

Open the .sln file to confirm it’s proper. (Sad that highlight.js doesn’t support .sln.)

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyGame.Server", "MyGame.Server\MyGame.Server.csproj", "{26648224-98E5-41F1-9C31-FD4626A9F721}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyGame.Api", "MyGame.Api\MyGame.Api.csproj", "{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}"
EndProject
Global
	GlobalSection(SolutionConfigurationPlatforms) = preSolution
		Debug|Any CPU = Debug|Any CPU
		Debug|x64 = Debug|x64
		Debug|x86 = Debug|x86
		Release|Any CPU = Release|Any CPU
		Release|x64 = Release|x64
		Release|x86 = Release|x86
	EndGlobalSection
	GlobalSection(ProjectConfigurationPlatforms) = postSolution
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Debug|Any CPU.Build.0 = Debug|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Debug|x64.ActiveCfg = Debug|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Debug|x64.Build.0 = Debug|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Debug|x86.ActiveCfg = Debug|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Debug|x86.Build.0 = Debug|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Release|Any CPU.ActiveCfg = Release|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Release|Any CPU.Build.0 = Release|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Release|x64.ActiveCfg = Release|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Release|x64.Build.0 = Release|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Release|x86.ActiveCfg = Release|Any CPU
		{26648224-98E5-41F1-9C31-FD4626A9F721}.Release|x86.Build.0 = Release|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Debug|x64.ActiveCfg = Debug|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Debug|x64.Build.0 = Debug|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Debug|x86.ActiveCfg = Debug|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Debug|x86.Build.0 = Debug|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Release|Any CPU.Build.0 = Release|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Release|x64.ActiveCfg = Release|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Release|x64.Build.0 = Release|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Release|x86.ActiveCfg = Release|Any CPU
		{19865AC1-8B76-4F0F-B428-212DDA4A1EAF}.Release|x86.Build.0 = Release|Any CPU
	EndGlobalSection
	GlobalSection(SolutionProperties) = preSolution
		HideSolutionNode = FALSE
	EndGlobalSection
EndGlobal

It’s fine as long as MyGame.Server and MyGame.Api are referenced.

Now the structure should look as below.

MyGame
├── MyGame.Api
│   ├── Class1.cs
│   ├── MyGame.Api.csproj
│   └── obj
├── MyGame.Server
│   ├── MyGame.Server.csproj
│   ├── obj
│   └── Program.cs
├── MyGame.sln <--- Hi there!
└── MyGame.Unity
    ├── Assembly-CSharp-Editor.csproj
    ├── Assembly-CSharp.csproj
    ├── Assets
    ├── Library
    ├── Logs
    ├── MyGame.Unity.sln
    ├── Packages
    ├── ProjectSettings
    ├── Temp
    └── UserSettings

Step 2: Reference Unity-Generated .csproj files

Try opening MyGame.sln in Visual Studio or Rider and look at the explorer.

img.png

The Unity project is not found because we haven’t added Unity-Generated .csproj files, namely Assembly-CSharp-Editor.csproj and Assembly-CSharp.csproj, to the root solution. Let’s add them.

dotnet sln add MyGame.Unity/Assembly-CSharp-Editor.csproj
dotnet sln add MyGame.Unity/Assembly-CSharp.csproj

Back in the editor, toggle the view from “Solution” to “File System” and you will see everything in one view:

img_1.png

Now you are done setting up the solution.

But /MyGame.Unity can’t yet reference anything in /MyGame.Api; try creating a struct in /MyGame.Api and reference it in /MyGame.Unity. It reports a compile error.

img_2.png

It’s an expected behavior, as we didn’t help /MyGame.Unity reference /MyGame.Api. To help reference external api, we need to make it a custom package.

Job 3: Set up a Package

Step 1: Make API a package

Create a file named package.json at the root of the api.

cd MyGame.Api
touch package.json

Fill it with the following:

{
  "name": "com.companyname.mygame.api.unity",
  "version": "1.0.0",
  "displayName": "MyGame.Api.Unity",
  "description": "MyGame.Api.Unity"
}

Create a file named MyGame.Api.Unity.asmdef at the same location. The file’s name must be different from the name of the api directory. i.e., it can be anything but MyGame.Api.asmdef.

touch MyGame.Api.Unity.asmdef

Fill it with the following:

{
    "name": "MyGame.Api.Unity"
}

Now the API directory has become a unity package whose binary you can freely import.

Step 2: Import the API as a custom package

In the Unity editor, navigate to Window - Package Manager. Click on + Icon at top left corner and select Install Package from disk....

img_3.png

In the popup, select package.json under /MyGame.Api and click on “Open”. The package should be installed.

Step 2.5 : Troubleshooting GlobalUsings.g.cs(2,1): error CS8773: Feature 'global using directive' is not available in C# 9.0. Please use language version 10.0 or greater.

If you encounter this error, it is due to an auto-generated file similar to the following.

// <auto-generated/>
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Threading;
global using global::System.Threading.Tasks;

As for the time of writing, Unity 6, its latest version, supports C# up to 9.0, which does not support global using.

To solve it, simply remove <ImplicitUsings>enable</ImplicitUsings> from MyGame.Api.csproj.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings> // remove this line
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Don’t forget to delete /bin and /obj under /MyGame.Api.

Step 3: Confirm it compiles

img_4.png

Don’t look at the IDE but simply enter the playmode in the Unity editor. It runs fine.

But there is a problem: you still see error message in the IDE, though it compiles in the Unity editor.

img_5.png

It reports errors because the IDE doesn’t know you provided Unity with external codes. We did it via UPM and .asmdef but they don’t notify .csproj or .sln files of it.

The final step is thus to notify them so that the IDE detects the import.

Job 4: Use SlnMerge to notify the IDE

SlnMerge is a tool to automatically merge a Unity-Generated solution to another. This final job uses it to tell the IDE that Unity has the required binaries.

Step 1: Install SlnMerge

In the Unity editor, navigate to Window - Package Manager. Click on + Icon at top left corner and select Install Package from git URL... Note it’s not from disk.

img_3.png

Type https://github.com/Cysharp/SlnMerge.git?path=src into the input and press Install.

img_6.png

SlnMerge should be installed shortly. If it doesn’t, follow the instructions in its GitHub README.

Step 2: Set up SlnMerge

Create MyGame.Unity.sln.mergesettings right under /MyGame.Unity, not under /Assets.

touch MyGame.Unity.sln.mergesettings

Fill it with the following:

<SlnMergeSettings>
    <MergeTargetSolution>../MyGame.sln</MergeTargetSolution>
</SlnMergeSettings>

The target solution directory is the relative path to the root solution.

Step 3: Run SlnMerge and Open the IDE

To run SlnMerge, just close the Unity editor and re-open it. SlnMerge’s script will run on its loading.

When the editor finishes loading, open MyGame.Unity.sln in the IDE. Note it’s not MyGame.sln, the solution we created on command line. Open MyGame.Unity.sln which Unity auto-generated.

And if you look at the IDE’s explorer tab, you will see the three directories at once:

img_7.png

and you can confirm the IDE reports no compile error.

img_8.png

That’s it. Now you are all set. Happy hacking!