DEV Community

Unicorn Developer
Unicorn Developer

Posted on

Unity3D for .NET Developers: How to get started

As a C#/.NET developer, have you ever thought about getting into game development with Unity3D? After all, it uses the same language. Is it really the same, though? Can you simply copy code written for .NET into a Unity3D application and expect it to work without any issues? Let's take a closer look to find out what extra knowledge C#/.NET developers need to comfortably and effectively develop games.

1424_unity3d/image1.png

We published and translated this article with the copyright holder's permission. The author is Kekchpek.

Compilation and execution

First, we'll compare how our code is compiled and executed in .NET and Unity3D. In .NET, the process is fairly straightforward and transparent. The C# code is first compiled into IL, then packaged into DLL or EXE files, and finally executed by a virtual machine. However, this approach has one drawback that has long posed a major obstacle to cross-platform development for C# programmers: dependence on a virtual machine. Today's .NET is nothing like its predecessor, .NET Framework, which was exclusively Windows-based. Before .NET emerged, the most popular solution for cross-platform development was the Mono virtual machine, which could also execute IL code and served as an alternative to .NET Framework.

Unity3D originally ran on the Mono virtual machine. Today, Mono remains one of the options for running C# code in Unity3D. The second option is IL2CPP, a newer technology developed by Unity Technologies, which is gradually replacing Mono. Code compiled with IL2CPP runs faster and doesn't require a pre-installed or bundled virtual machine. As for how IL2CPP works, its name speaks for itself. Roslyn first compiles C# into IL, which is then translated into C++. Finally, the C++ compiler converts the code into machine code. The C++ compiler varies depending on the target platform. In terms of C#, however, the code behaves much like it does in .NET. Unity3D uses a very similar garbage collector, standard library, and so on.

So, although the compilation process differs, the average developer won't notice much difference. One drawback is that it's nearly impossible to predict how the code will compile on different platforms. IL2CPP bugs are certainly a possibility, but encountering them is as unlikely as running into bugs in .NET.

Any C# DLL libraries that contains IL can be used in Unity3D. During compilation, Unity runs the code through IL2CPP and recompiles it into C++.

However, things aren't that simple when it comes to C# versions. Unity uses C# 9, which is rather outdated, and even that version lacks support for certain features. You can read more about it here.

Libraries and compatibility

As I mentioned earlier, libraries written in C# can be compiled for different platforms using IL2CPP, but there are some limitations. Unity supports compatibility with .NET Framework and .NET Standard libraries. However, that doesn't mean libraries compiled for .NET won't work. They might work, but not all of them will. It depends on whether their IL code contains anything that Unity3D can't translate into C++.

The standard library works in Unity3D, but with a few caveats. In other words, we have access to all the features of standard C#, including collections, concurrency, Tasks, LINQ, networking, and more. However, Thread and Task (when used to run separate threads), for example, won't work in WebGL builds (games that run in browsers). This is one of the most obvious limitations, but one may occasionally encounter others related to the specifics of a particular platform.

From a coding perspective, Unity3D acts as an extensive library within the UnityEngine namespace. The UnityEditor namespace also exists that contains the editor API, which enables developers customize windows, builds, plugins, and more.

How to organize code into modules

In .NET, we're used to organizing code into separate modules via .sln and .csproj files. Unity3D has them too, but we can't work with them directly. Unity3D creates .sln and .csproj files, purely for the developers' convenience. This allows popular IDEs to provide proper syntax highlighting, error detection, and other useful features. Unity itself doesn't rely on the contents of .sln or .csproj files at any stage of the project build process. As a result, one common mistake beginners make is adding a library via their IDE. This only modifies the .csproj file, but Unity remains unaware of the change. So, when a dev tries to compile the project, Unity starts throwing errors even though the IDE shows no errors at all.

To control how Unity3D splits code into modules and what properties those modules have, we need to use Unity's built-in tools. The main feature we'll work with here is Assembly Definition, Unity's equivalent of a .csproj file in .NET. To use Assembly Definition, we need to create an .asmdef file in one of the project folders. We can do this via the Unity graphical interface. Once we create the file, Unity treats all .cs files in that folder and its subfolders as a separate module. It generates a dedicated .csproj file for this code and enables us to specify dependencies required by that particular module. We can also use the module as a dependency. We configure everything using Unity3D's graphical interface, and Unity reflects those settings in the generated .csproj files.

By default, we don't create any .asmdef files, Unity automatically divides code into four modules based on the names of the folders containing the .cs files:

  • Assembly-CSharp-Editor-firstpass: the "*/Standard Assets/**/Editor", "*/Pro Standard Assets/**/Editor" and "*/Plugins/**/Editor" folders
  • Assembly-CSharp-firstpass: the "*/Standard Assets", "*/Pro Standard Assets" and "*/Plugins" folders
  • Assembly-CSharp-Editor: the "*/Editor" folders
  • Assembly-CSharp: any other folder

Using Assembly Definitions is generally considered a good practice, though not every project has them. The key point to keep in mind is the difference between the Editor and Runtime modules. Editor contains code that runs only in the Unity editor and doesn't make it into the game build. Developers usually use this code to create additional tools for game designers, artists, and programmers. We can specify in any Assembly Definition that it's intended only for the editor.

How to integrate third-party libraries

The easiest way to add a third-party library is to simply copy its DLL into the project folder. Unity automatically scans the project folder for DLLs and adds them as dependencies to the generated .csproj files. In the Assembly Definition settings, we can explicitly specify which DLLs to include as dependencies and which to exclude.

We can also directly add source code instead of using DLLs. This might seem a little strange to a .NET developer, but it's actually a fairly common way to distribute plugins and SDKs for Unity3D. Unity even has a dedicated packaging mechanism called .unitypackage. These files simplify the distribution of assets that need to be imported into specific parts of a project. Usually, when we want to download some open-source SDK, we get a .unitypackage file from GitHub and open it in the Unity editor. The editor adds the necessary source files to the project or updates existing ones, first enabling a developer to review the package contents and choose which files to import. Some SDKs even offer to remove outdated files from previous versions after importing the .unitypackage. However, I'm not sure whether this feature is built into the Unity editor or if SDK developers implemented it themselves.

A more convenient way to manage dependencies is the Unity Package Manager. It's a package manager similar to npm, with a graphical interface built into the Unity Editor. As with npm, each package used in the project has a package.json file that describes the package and its dependencies. Every package stores this file in its root directory.

The project itself, however, uses manifest.json instead of package.json, unlike npm. Despite the different name, manifest.json serves a similar purpose: it lists the project's dependencies and the package registries from which Unity can retrieve them. Instead of specifying a version, we can provide a link to a GitHub repository or a specific directory within that repository from which Unity should download the dependency. We can also use links to private repositories after configuring Git Credential Manager. Unfortunately, we can specify GitHub links only for dependencies listed in manifest.json.

In other words, we can't use GitHub links for transitive dependencies (dependencies of other dependencies). This limitation makes managing dependencies less convenient when building infrastructure for large-scale projects. One solution is to create our own package registries and host them in the cloud. These registries can be either private or public.

OpenUPM (Open Unity Package Manager) deserves a special mention. It's an open package registry for the standard Unity Package Manager where anyone can publish their packages. Most packages distributed as .unitypackage files are also available on OpenUPM. There are also many NuGet packages available on OpenUPM. However, not every NuGet package developer—or developer of any other package—takes the time to publish their packages on OpenUPM. As a result, some packages listed on OpenUPM are just forks of the original projects, tweaked to work with OpenUPM. I'd still advise against dismissing these packages outright.

If you're a company that takes its code supply chain seriously and carefully controls what goes into its projects, the best approach is to set up your own package registry. You can then validate incoming packages and download everything from that registry. But if you're developing a small game, a prototype, or if you simply lack the resources to maintain a robust infrastructure for your project, OpenUPM is an excellent solution.

And since this article is aimed at .NET developers, I'd like to mention NuGet for Unity. It's a plugin that adds a user-friendly interface for managing NuGet packages in Unity. I wouldn't recommend it, despite having used it a lot myself. The main disadvantage of using NuGet is that it brings multiple sources and dependency managers into a project. This can quickly turn into a major headache in large projects with lots of dependencies. Managing package versions and resolving conflicts between transitive dependencies can be serious challenges.

In short, I recommend using the Unity Package Manager alongside OpenUPM and other package registries wherever possible. Resort to other ways of adding dependencies only when the Unity Package Manager isn't an option.

Unity3D architecture

Like many game engines, Unity3D uses a tree-based structure to organize objects. An application contains multiple scenes (UnityEngine.Scene), and each of them contains numerous game objects (UnityEngine.GameObject) that act as containers for components (UnityEngine.Component). A component is a basic logical unit that contains executable code. Game objects (GameObject) can also be arranged into hierarchical structures.

1424_unity3d/image2.png

Every GameObject has one mandatory component that can't be removed: UnityEngine.Transform. It controls the object's position in the scene and its place in the hierarchy compared to other GameObjects. We can add or remove other components either while working with the scene in the editor or at runtime through code.

Entry point

I believe we have enough context now to move on to the more hands-on topics. The application entry point is the best place to start. In Unity3D, we don't have access to the Main function. Instead, we have to work within Unity's architecture, which loads the first scene in the scene list when the application starts. The only option available to us is to create a custom component and then link it to an object in the starting scene. Once the scene has loaded, the code in that component will start executing.

To create a component, we'll create a class that inherits from UnityEngine.MonoBehaviour. Here's a simple example of a custom component:

using UnityEngine;

public class MyComponent : MonoBehaviour {

  private void Start() {
    // do something on component start working
  }

  private void Update() {
    // do something every frame
  }

}
Enter fullscreen mode Exit fullscreen mode

Now, we can use the editor to create as many instances of this class as needed and attach them to any GameObject in the scene. Each component's Start method will be called at the beginning of its lifecycle, while Update will run every frame. Unity orders all these method calls according to its Script Lifecycle, which is a key architectural element in Unity3D. When designing how code interacts with the engine's API, developers need to understand exactly where each code fragment should run in the cycle.

This is where Unity3D starts getting quirky. Notice that Start and Update don't override any base methods; they aren't part of an interface, and aren't even public. Yet third-party code still calls them. We rarely see this approach in .NET, where such things usually rely on reflection. Unity, however, can afford to bend the language rules. Acting as a compiler, it integrates code into its event system without requiring the explicit language constructs normally used for this purpose. In other words, Unity binds methods by name, almost as if we were writing JavaScript. The methods can be private or public—it makes no difference. We can also use inheritance with virtual methods. For example, we can create components that inherit from MyComponent and override the necessary virtual methods.

Unity3D's event system includes many other methods that Unity calls at various stages of the Script Lifecycle. We could probably call this reflection in terms of the concept, but the way it works is fundamentally different from using System.Reflection or similar runtime tools. All the magic happens at compile time, so it doesn't affect the performance of such calls.

Unity provides a class called UnityEngine.UIBehaviour, which can also be inherited from to create components. It's a MonoBehaviour subclass whose main purpose is to provide a set of empty virtual methods related to Unity UI events. However, nothing stops us from inheriting directly from MonoBehaviour and defining all the necessary methods without overriding any virtual ones. This feels a little odd, as if even the Unity developers haven't quite settled on a consistent way to best implement Script Lifecycle functions.

How to create components

We've already encountered one unusual aspect of C# code in Unity, and there's more to come. As I mentioned earlier, we can add components either using the scene editor or at runtime via code. Let's start with adding them in the editor. This part is straightforward: we select an object in the scene, click the Add Component button in the Inspector window, choose MyComponent, and Unity adds it to the object. To understand how this works, it's worth taking a look under the hood.

Each scene exists as a separate file that contains everything in that scene, including the entire object hierarchy and all its components. We create an instance of our class when we add a component. When we save the scene, Unity serializes that instance into the scene file. Once we build and run the game, Unity deserializes all our components as it loads the scene, creating new instances of their classes. If we apply the UnityEngine.SerializeField attribute to any of our fields, we can save a component with a specific value for that field. The editor even displays a UI control that enables us to edit the field on the component we've added. By default, Unity serializes public fields.

Adding components at runtime is also quite straightforward. We use the GameObject.AddComponent<T>() method for this. It's an instance method, so we first need to obtain a GameObject instance. This isn't a problem, as the Unity API provides plenty of ways to retrieve a particular GameObject. The most common approach is to use the MonoBehaviour.gameObject property.

By the way, the Unity Engine's API naming conventions may look unusual to .NET developers: properties and fields start with a lowercase letter. Still, in almost every project I've seen, developers stick to the naming conventions commonly used in .NET. Here's an example of a component that adds a copy of itself to the same GameObject three seconds after it starts executing:

using UnityEngine;

public class CopySelfComponent : MonoBehaviour {

  private float _passedTime;

  private void Update() {
    if (_passedTime > 3f) 
    {
      // gameObject is a public property of MonoBehaviour
      gameObject.AddComponent<CopySelfComponent>();
    }
    else 
    {
      // Time.deltaTime is a static property that stores the delta between
      // the start of the previous and current Update phases
      // in the Script lifecycle
      _passedTime += Time.deltaTime;
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

At this point, an experienced developer might ask, "What about object dependencies?" It's no secret that OOP revolves around creating various classes and establishing clear, precise, and elegant relationships between them. In most programming languages, including C#, we use constructors to define a class's dependencies. I've already described two ways to create a component, but neither involves a constructor. The reason might blow your mind: constructors for MonoBehaviour subclasses are UB in C#. For those unfamiliar with C++ and the concept of undefined behavior, here's how I'd describe it: you can use it, but it won't end well. Whatever you're trying to achieve, it'll probably fail. In other words, we can't use constructors for components.

There's a formal alternative: the Awake() method. It's the first method Unity calls when creating a component, with a few caveats we won't get into here. However, this method doesn't take any arguments either. A common workaround for dependency injection is to define a separate method, usually called Construct(...) or Initialize(...), and pass all the required dependencies to it. Many developers will immediately recognize the downsides of this approach: we end up with an object in an inconsistent state immediately after its creation. That's something we have to live with. The usual solution is to encapsulate object creation in factories or use dedicated DI frameworks with their own APIs for creating objects and components. Here's what a typical component might look like when written using one of the most popular DI frameworks, Zenject:

using UnityEngine;
using Zenject;

public class MyComponent : MonoBehaviour {

  // Here, we can't even use `readonly` for dependencies, 
  since they change after the object is created.
  private IService1 _service1;
  private IService1 _service2;
  private IService1 _service3;

  // An attribute that tells the DI framework that this is a method 
  for dependency injection
  [Inject]
  public void Construct(
    IService1 service1,
    IService2 service2,
    IService3 service3) {
    _service1 = service1;
    _service2 = service2;
    _service3 = service3;
  }
  ...

}
Enter fullscreen mode Exit fullscreen mode

Things aren't so bad when it comes to classes that don't inherit from MonoBehaviour. We can use them just as we would in .NET. In large projects, developers typically implement most of the application's business logic in regular classes and use components as an integration layer with the engine.

The intricacies of writing game code

Overall, I've covered the main differences in the language, but Unity3D development also has a few specific aspects. Remember that games are resource-intensive applications, mainly because of their graphics. However, we also need to handle code more carefully to avoid putting unnecessary strain on a device that's already working hard. So, here are a few things to keep in mind:

Minimize calls to "expensive" APIs

The Unity library offers plenty of ways to tank an application's performance. Almost all API calls that create or access entities are rather slow. Of course, "slow" in this context doesn't refer to the pace perceived by a human observer; rather, it's a measure of the speed in terms of CPU standards. Here's an example of code that gradually changes a UI image's color from one to another and back again:

using UnityEngine;
using UnityEngine.UI;

namespace Components
{
    public class MyBehaviour : MonoBehaviour
    {

        // When the scene loads, Unity prioritizes 
        the serialized field value. 
        // If the component is being created for the first time, 
        Unity uses the initialization value instead.
        [SerializeField]
        private Color _color1 = Color.red;

        [SerializeField]
        private Color _color2 = Color.blue;

        private void Update()
        {
            var img = this.GetComponent<Image>(); // THIS CODE WILL RUN SLOWLY

            // Some simple math is involved here. I'll spare you the details.
            // You can look into it on your own if you're interested.
            img.color = Color.Lerp(_color1, _color2,
            Mathf.PingPong(Time.time, 1f));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If the code accumulates enough of such calls, they'll significantly impact performance. A better approach is to cache Image in Awake() or declare it as a serialized field so we can assign its value in the editor. We can drag and drop many types derived from UnityEngine.Object into UI fields in the editor to assign references to specific instances like this:

using UnityEngine;
using UnityEngine.UI;

public class MyBehaviour : MonoBehaviour
{
    [SerializeField]
    private Color _color1 = Color.red;
    [SerializeField]
    private Color _color2 = Color.blue;

    private Image _image;

    private void  Awake()
    {
        _image = GetComponent<Image>();
    }

    private void Update()
    {
        _image.color = Color.Lerp(_color1, _color2, 
        Mathf.PingPong(Time.time, 1f));
    }
}
Enter fullscreen mode Exit fullscreen mode

Or like this:

using UnityEngine;
using UnityEngine.UI;

public class MyComponent : MonoBehaviour
{

  [SerializeField]
  private Color _color1 = Color.red;
  [SerializeField]
  private Color _color2 = Color.blue;

  [SerializeField]
  private Image _image;

  private void Update()
  {
    _image.color = Color.Lerp(_color1, _color2, 
    Mathf.PingPong(Time.time, 1f));
  }
}
Enter fullscreen mode Exit fullscreen mode

Here are some other commonly used—though not the only—methods that can cause performance issues:

  • GetComponent<T>()
  • GetComponents<T>()
  • GetComponentInChildren<T>()
  • GetComponentInParent<T>()
  • FindObjectOfType<T>()
  • FindObjectsOfType<T>()
  • Find(string)
  • FindWithTag(string)
  • FindGameObjectsWithTag(string)

How to create objects

Object creation is another aspect of Unity development that deserves careful attention. I'm referring to both Unity3D objects (GameObject and MonoBehaviour) and regular class instances. In short, the best approach for performance is to avoid creating objects altogether. This is why Unity3D developers extensively use object pooling. It's hardly a groundbreaking idea, and Unity3D already provides a built-in class for this purpose: UnityEngine.Pool.ObjectPool<T>. Why does this matter in the game development industry? There are different reasons for Unity3D objects and regular class instances.

For regular class instances, the issue is straightforward: memory allocation. One of the biggest threats to application performance is the garbage collector. Just as in .NET, the garbage collector pauses code execution to manage memory. Creating large numbers of short-lived objects can trigger frequent garbage collection, causing FPS drops during those pauses. Developers may encounter this issue when their code creates various collections to process and transfer data, DTOs, and similar objects.

Although they usually have a longer lifespan, memory allocation for GameObject instances and components is another concern. The issue once again lies with the slow API responsible for creating them. Sometimes, we need to transition from one game state to another while displaying large numbers of objects along the way. For example, we might load an environment, spawn a large number of units, or create bullets when firing a weapon, which is a now-classic use case. Our goal in these situations is to create game objects as quickly as possible to prevent FPS drops and loading screens that frustrate players. That's where object pooling comes in, ideally with the warm pool. However, we shouldn't forget about the balance between memory usage and performance. Pooling too many objects will cause the application to use too much memory, creating its own set of problems. So, we should make smart use of the device's available resources.

Structs

For the same performance reasons, it's a useful skill for any developer to know when to use a struct instead of a class, as this helps reduce the load on the garbage collector. I won't go into much detail here, since the rules are roughly the same as in .NET. It's also important to remember about boxing and avoid unnecessarily allocating structs on the heap.

How to destroy objects

When it comes to destroying objects, Unity has a small peculiarity. Regular classes depend on the garbage collector. However, things become more complicated when it comes to objects that are part of the engine. Let's take GameObject, for example. When we write new GameObject(), we create a new instance of the GameObject class. At the same time, Unity adds the object to the hierarchy of the currently open scene. It may seem obvious that if the code no longer contains any references to this instance, it will still be part of the scene and will not be removed by the garbage collector.

To explicitly remove the object from the scene, we need to call the UnityEngine.Object.Destroy(UnityEngine.Object) static method and pass the instance as an argument. This may seem obvious for GameObject and MonoBehaviour subclasses. We know these objects are part of the scene, so it makes sense that we need to take explicit action to remove them. However, there are less obvious cases, too.

For example, the Texture2D class is an abstraction of a texture, which is an image. We can create a Texture2D instance and assume that we're the only ones holding a reference to it, so once we lose all references, the garbage collector will remove it. Unfortunately, that's not how it works. The rule is simple: we should call UnityEngine.Object.Destroy(UnityEngine.Object) for all explicitly created subclasses of UnityEngine.Object. The reason is that C# objects in the Unity API are simply wrappers for native objects belonging to the engine's C++ code. If we lose all references to our object, only the C# wrapper is removed. However, the C++ object under the hood still occupies memory. On the other hand, calling UnityEngine.Object.Destroy(UnityEngine.Object) destroys only the C++ object. So, after calling UnityEngine.Object.Destroy(UnityEngine.Object), we still have the same C# object, but all of its methods will throw errors because the object that handles the logic has been destroyed.

We need to be careful when comparing UnityEngine.Object subclasses to null. Unity overrides the equality operator for UnityEngine.Object. So, in addition to checking whether both operands are null, Unity also checks whether the C++ object has been destroyed. If it has, comparing the object to null will return true. This behavior can be confusing and makes comparisons to null relatively expensive. So, it's better to avoid them in frequently executed code as well.

Linq

LINQ extension methods are incredibly convenient. They're also highly effective in middleware-based architectures, where different layers of an application can process the same enumerable object in sequence without repeatedly iterating over its elements. However, there's one major drawback to using LINQ with Unity3D: its extension methods return objects that require heap allocations.

This brings us back to the issue of unnecessary object creation we discussed earlier. The overhead is negligible on its own, but constantly using it in frequently executed code can create a problem. I recommend keep using LINQ if you follow the middleware-based approach. However, try to replace LINQ methods with regular for and foreach if you're using them within a single method.

Also, be careful with methods that execute LINQ queries, such as ToList(), ToArray(), and so on, as they can significantly increase memory usage. Call them only when you actually need the query result as a concrete collection, or avoid them altogether. That said, I don't think things are any different here compared to .NET.

Concurrency and asynchronous programming

Concurrency in Unity3D can be tricky. As I mentioned earlier, code dispatched to another thread simply won't run at all on WebGL. I haven't encountered any concurrency issues with how threads behave at the OS level on other platforms. However, almost every Unity API call will throw errors if we try to make it from a thread other than the main one. Experienced Unity developers might point to the Job System, but as far as I know, it only provides limited access to Transform.

On the one hand, this is nice. We don't have to struggle too much with synchronization, and neither do Unity3D developers. Many Unity developers don't even know about the lock keyword, which is considered one of the cornerstones of concurrency in C#. On the other hand, we may sometimes want to use multiple CPU cores to modify the properties of a large number of Unity objects.

Still, Unity makes extensive use of concurrency under the hood, and its API provides asynchronous operations for loading data from disk, handling network communication, and performing some other resource-intensive tasks. That said, developers at Unity Technologies aren't particularly fond of the async/await syntax, so we often have to make do with callbacks and events. Of course, nothing stops us from wrapping these in a proper async API using TaskCompletionSource and a few other methods.

Unity also offers a rather unconventional approach to asynchronous programming called coroutines. Basically, coroutines enable developers to pass an enumeration to the Unity3D engine via its API. Each element tells the engine when to retrieve the next one. When combined with the yield operator, this works as a makeshift version of async/await. It looks something like this:

public IEnumerator Coroutine() {
  Debug.Log("Do something");

  yield return new WaitForNextFrame();

  Debug.Log("Do something in the next frame")

  yield return new WaitForSeconds(2f);

  Debug.Log("Do something after two seconds");
  var t = Task.Delay(4000);

  yield return new WaitUntil(() => t.Completed);

  Debug.Log("Do something after 4 more seconds.");
}

...

someMonoBehaviourInstance.StartCoroutine(Coroutine());

...
Enter fullscreen mode Exit fullscreen mode

You may have noticed that starting a coroutine requires calling a method on a specific object instance. This tight coupling to an object is one of the advantages of coroutines: Unity cancels a coroutine when we destroy its associated object using UnityEngine.Object.Destroy(obj). In every other respect, though, coroutines are simply a less convenient alternative to async/await.

Unity3D also has its share of issues with Task. Firstly, it runs on the thread pool, so we run into the same problem: we can't use the Unity API from those threads. Secondly, Unity3D developers try to avoid allocating memory on the heap, and Task is a class that allocates memory on the heap. ValueTask helps reduce this overhead, as does UniTask, its Unity-specific counterpart. UniTask is an open-source plugin that offers an alternative to ValueTask. It provides additional optimizations and enhances the user experience when working with the Unity API. The library also includes various extension methods for Unity classes, providing convenient ways to use them with await.

Unity3D also has its own implementation of SynchronizationContext. It slightly streamlines the use of Task and ensures that the code after await won't run on a different thread. If the object we're await-ing executes on Unity's main thread, the code after await will run as soon as the execution finishes. If we use concurrency instead, the code after await will run during an early stage in the Script Lifecycle. WPF uses a similar synchronization context.

Covering code with automated tests

Unity includes a built-in editor window for running and viewing tests. The test framework is based on NUnit, but there are a few differences. First, Unity supports two types of automated tests: PlayMode and EditMode, which are just regular NUnit tests. We can create usual C# objects, mock dependencies using a mocking library such as NSubstitute (also available through OpenUPM), perform various operations, and check the results. PlayMode tests, on the other hand, run the Script Lifecycle. They enable us to check how the MonoBehaviour subclasses behave over time, how they respond to various physics triggers in the Script Lifecycle, and so on.

In my experience, relatively few projects have made extensive use of PlayMode tests. Most teams move as much business logic as possible into standard C# classes. This leaves the MonoBehaviour subclasses as a thin layer between the C# architecture and the Unity API. I think the main reason is that testing anything related to the Script Lifecycle can be challenging. The engine's physics and execution systems don't provide APIs that offer the level of fine-tuning often required when writing automated tests.

One disadvantage of testing in Unity is the lack of support for asynchronous tests. This is where the coroutines mentioned above come in, replacing asynchronous operations. It looks something like this:

[UnityTest] // This is a special attribute for such tests
public IEnumerator AsyncTest() {
  // Arrange
  SomeService someSerivce = // create some service with mocked dependencies

  // Act
  Task<bool> asyncHandle = someService.SomeAsyncMethod();
  yield return new WaitUntil(() => asyncHandle.IsCompleted);

  // Arrange
  Assert(true, asyncHandle.Result);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Those were the main points to keep in mind when starting to write C# code in Unity3D. If you have any comments or notice any errors, please leave a comment on the article or open an Issue on GitHub.

Want to learn more?

The PVS-Studio team loves to publish valuable content for C# developers and folks working in game development. Subscribe to our newsletter to follow the articles from the PVS-Studio website.

Here are some articles:

Top comments (0)