A non-exhaustive reference document containing high-level guidelines on how to collaborate on any game development project.
Looking to learn about more positions in game-dev? Check this resource here.
An important consideration when beginning development on any size project, regardless of engine, is project structure. Good structure ensures that your project is maintainable and scalable, as well as promoting quality code standards.
There’s no one correct structure, but I heavily recommend one like this, which is a combination of official recommendations from Unreal and Unity, simplified a little:
|-- [Project]
|-- Audio
|-- Music
|-- Sound
|-- Art
|-- Materials
|-- Models
|-- Textures
|-- Level
|-- Levels (e.g. Maps or Scenes)
|-- User Interfaces
|-- [Prefabs in Unity can also go here]
|-- Source (or Scripts)1
|-- Narrative2
|-- Dialogue
|-- English
|-- [Other locales]
|-- Scripts (i.e., narrative scripts and storyboards)
|-- Docs3
|-- [Non-source settings or assets pertaining to built-in features]
|-- [Any Third Party Asset Folders]4
Read more about best practices for organizing your Unity project (which is also corroborated by this article).
Read more about the recommended project structure for Unreal Engine 5.
Read more about project organization guidelines for Godot.
While you can use GitHub Desktop as an easy first way to work with your repository, I highly recommend that you start to learn the Git CLI as soon as possible — especially since you will not get all the features of Git via GitHub Desktop.
Getting started: https://git-scm.com/book/en/v2/Getting-Started-What-is-Git%3F
Git docs: https://git-scm.com/docs
Note
Local: Only on your machine. Remote: Shared on a server.
- If you want to start working with a git repository you first need to clone it using this command, which will set the origin alias to the same url:
git clone <git-url>
Add the --recursive
command if the repository contains git submodules.
- If, for whatever reason, the origin URL changes, update it with:
git remote set-url origin <new-git-url>
- To, instead, create a remote repository from a local, unversioned (e.g. non-git repo), folder read this.
A standard Git workflow tends to follow these steps in order.
- To create a local branch that does not sync with an existing remote branch, use:
git branch <local-branch>
- To create a local branch that syncs with an existing remote branch, use:
git branch <local-branch> origin/<remote-branch>
This creates a local branch, “,” that is synced with a remote branch, “.” The two branches don’t need to, but should, share the same name.
- ⭐ To create and switch to a local branch that syncs with an existing remote branch, use:
git checkout -b <local-branch> origin/<remote-branch>
- To create and switch to a local branch that does not sync with an existing remote branch, use:
git switch -c <branch>
- ⭐ To switch to an already existing local branch, use:
git switch <branch>
- To fetch the latest changes in an existing remote branch, use one:
git fetch origin <branch>
git fetch
- After fetching, check the status of the current local branch compared to its remote using:
git status
- ⭐ To fetch and download the latest changes in an existing remote branch, use one:
git pull origin <branch>
git pull
The git status
command will let you know if your local repository is behind the local reference “origin/”, which corresponds to a branch on a remote called origin.
This status doesn't reflect the remote branch's current state – but the state of the local reference based on the latest fetched data. The command git pull
updates this status by first running git fetch
to synchronize with the remote repository, updating the local origin/master reference, and then performs a git merge
to integrate these updates into your current branch.
If there are any discrepancies between the status you see locally and what you see remotely, it’s likely you didn’t run git fetch
.
Caution
So many things will go wrong if you don’t understand these things and stay up-to-date
At this point, you can make changes by editing the project as you normally would. Once you are done for the day follow these steps:
- Use
git add .
to track all your changes (use git add to add a specific file or directory; usegit reset git
orgit reset <file>
to undo this step). - Use
git commit -m "<commit-message>"
to stage your tracked changes for a commit. - Use
git push
to apply those changes to remote.
Note
You can use `git commit -am "" to combine both steps 1 and 2.
If there is no tracking information for a branch use,
git push -u origin <remote-branch>
Alternatively,
git branch --set-upstream-to=origin/<remote-branch> <local-branch>
to add tracking information without pushing first.
Important
This step should always be performed – unless you already specified this information on branch creation.
You should be pushing code every day you work on it, regardless of what branch it is to -- or how little the contribution is.
Sometimes, it is impossible to store some assets in the same repository as your game code. For example, some third-party assets might be protected by an EULA that prevents you from redistributing, and you certainly shouldn’t “open-source” paid assets. Moving assets to a separate repository can allow you reduce space usage limits. Popular solutions include Perforce
You can specify the folder to place a submodule in using:
git submodule add <submodule-origin-url> <relative-path-to-submodule>
To pull the latest changes from a submodule for the first time use git submodule update --init --recursive --remote
, after which use: git pull --recurse
. If that doesn't work cd
into the submodule's root directory and run git pull origin <branch>
. Like mentioned earlier, set up tracking information if there is not any already; this can be done by running git checkout -b <branch> <origin>/<branch>
(see more) in the same directory.
To push a commit to a submodule, you'll need to cd
into each submodule, from which you can issue git commands as usual. For submodules, however, this appraoch is generally preferred:
git checkout <branch>
git add .
git commit -m "<commit-message>"
git push
cd <out-of-submodule-and-into-the-repository>
git add .
git commit -m "<commit-message>"
git push
Once the work on your branch is completed, you can merge your branch into master using a merge request, which you can read about here.
Despite conventional wisdom, [a number of professional gaming studios](a number of professional gaming studios) recommend pushing directly to the current production branch (e.g. main). This is known as trunk-based development, which you can read about here. This style of development significantly reduces merge conflict errors and keeps a whole team up to date with the latest work. You can manually mark releases in most, if not all, git managers (e.g. GitHub); use this feature.
You don’t need a branch that has already been merged to main; these can be deleted off of Github but still need to be removed locally.
- ⭐ To delete a local branch that has been merged to remote, use:
git branch -d <branch-or-branches>
- To delete a local branch that may not have been merged to remote, use:
git branch -D <branch-or-branches>
You can list multiple branches separated by a space (see more).
Merging can be performed like this:
git switch A
git pull
git merge B
Then, Fix any merge conflicts and commit and push your changes. In some cases, this can be done directly in GitHub in one click.
Note
More on branching and merging: https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging
In many cases, a merge conflict can be avoided by simply remembering to stay up-to-date with the remote changes (I’ve had this happen by Git not recognizing a Unity scene file as having merge commit markers). In the case this fails, you can resolve a conflict by using an editor such as Rider, VScode, or Meld. If, for some reason, git does not recognize the merge conflict, you can try to manually mark it as conflicted, and retry. If this still fails to work, you may need to resolve it by hand. Understand that the skeleton of a merge conflict looks like this:
<<<<<<< HEAD
Changes from the current branch (e.g., the changes YOU made).
=======
Existing changes from a branch you're trying to merge into your current branch (e.g., the changes SOMEONE ELSE made).
>>>>>>> <branch>
Decide which change you want to accept, and delete everything else in the merge conflict, including the merge conflict markers to resolve it. Note that an empty change denotes deletion.
For example, to accept only your changes, the above conflict would be resolved as:
Incoming changes from the branch you are merging into (i.e., changes from the current branch – e.g., the changes YOU made).
To completely discard any and all local tracked changes, replacing them with the latest remote changes, use this irreversible command:
git reset --hard <branch>
If any local changes still persist after running this command, first, make sure your changes are tracked; then, run this command that will permanently erase every local change not in remote such as “temporary build artifacts or merge conflict files” (git clean docs):
git clean -df
A deatched head means that you have checkouted a specific commit instead of a branch, you will get a warning in the format HEAD detached at <short-commit-hash>
.
To save any changes you've made in this stage, follow these steps:
- Put your changes on a temporary, local branch using
git branch <temporary-branch>
- Checkout your desired branch (e.g. main) using
git checkout <target-branch>
If you want to instead discard your local changes, see the above section.
Finally run git merge <temporary-branch>
while on <target-branch>
.
On Windows, press Esc
then type :wq
; press Enter
.
You may have gotten this if you used git commit
, instead of git commit -m “<commit-message>”
– which is OK if you prefer it that way; however, It can happen for a number of other reasons.
If you receive the error Filename too long
on Windows, run the following command in an administrator command prompt (source):
git config --system core.longpaths true
Unity’s scenes and prefabs are both stored in very long, erratic files that are often mistaken by git as binaries even when represented as text. This makes them difficult to merge. For this reason, only one person should be working on a given scene or prefab at any time. If you were using a different Git manager, like Perforce, you could lock these types of files.
You can use continuous integration tools (e.g. Game CI), in combination with merge requests for merge requests, which you can read more about here.
See an example CI/CD pipeline from Riot Games here. Read a thesis here. This section is a stub, because it is not affordable for small scale development projects – locally run tests are much more useful within these constraints, in my opinion.
Ink and Yarn Spinner are the most popular, open-sourced systems for writing game scripts (i.e. dialogue) that should be preferred over a built-in system (such as using lists, strings, plain text, etc.). Although some people prefer Ink’s writing experience over Yarn’s, Ink lacks localization support and a built-in way to add speaker names to text. Yarn also has a VSCode extension, which makes writing scripts very straightforward. I prefer to use Yarn for these reasons.
Topic | URL |
---|---|
Spatial Communication | https://www.youtube.com/watch?v=AKeUZVikPV8&list=WL&index=2 |
Non-Linear Design | https://www.youtube.com/watch?v=CTBor4rhnQs&list=WL&index=1 |
The organization of the Source folder will look different depending on the engine’s needs. The general idea is to ensure that each feature-set has a single responsibility and to minimize dependencies and circular references between them.
Briefly, here are some common examples of multiple features mistaken as one:
Misconception | Reason |
---|---|
Logic and its user interface (e.g. inventory and inventory UI) | UI should not depend on logic (see the MVC pattern). |
Input and logic that depends on input (e.g. input handling and player movement) | Input handling is a deceptively complex feature that should not be coupled with anything. |
A math library, which is always reusable, and logic that depends on it (e.g. AI algorithms and AI) | Libraries should be standardized across all features. It’s counterintuitive and unmaintainable to have multiple libraries with the same purpose. |
Note
By separating out these into unique features we can better maintain code.
|-- Source
|-- [Project Top Level Folder]
|-- [Feature]
|-- Editor
|-- [Project].[Feature].Editor.asmdef
|-- Runtime
|-- [Project].[Feature].asmdef
|-- Tests
|-- Editor
|-- [Project].[Feature].Editor.Tests.asmdef
|-- Runtime
|-- [Project].[Feature].Tests.asmdef
|-- Shaders
|-- [Feature]
Files ending with .asmdef
are Assembly definition files, which you can read more about here: in short, they allow a programmer to build isolated features that don’t need to be recompiled if no changes are made to them and their dependencies.
|-- Source
|-- [Project Top Level Folder]
|-- [Module]
|-- Private
|-- Tests
|-- [Feature]Tests.cpp
|-- [Module]Module.cpp
|-- Public
|-- Tests
|-- [Feature]Tests.h # if any
|-- [Module]Module.h
|-- [Module].Build.cs # if any1
|-- [Third-party libraries. Same layout as above]
FDefaultGameModuleImpl
if there is nothing unique to write.
In most cases, prefer to use Rider over any other IDE, if it is affordable or if you are a university student.
When using this IDE, you may find it convenient to put every feature in a top level folder of the same name as the project, below the Source folder, for better automatic namespacing. Using a Packages Third-party assets, most commonly as packages, are also relevant to game development. Prefer to install third-party assets as currently maintained packages, rather than in separate, top-level folders.
The package manager of choice for Unity is OpenUPM. Both Godot and Unreal Engine (although Plugins can be considered a substitute) do not have package managers.
These make collaborating much easier and the game much more maintainable. It’s good to start using these now.
Former Unity and Ubisoft principal engineer, Sebastian Aaltonen writes,
People seem to have vastly different opinion[s] about good code. Some people think it’s coe that is so good that it stays in the product for a long time and has lots of users. But I think good code is code that you can delete easily without breaking other code [and has] minimal dependencies.
https://x.com/SebAaltonen/status/1826589641962639740
and avoids overcomplicating things.
For example, if you’re not writing automated tests, there is no reason to separate commands and command handlers – even though that is the technically “correct” way of going about things.
Please, don’t mistake a lack of understanding for complexity; if something is too complex, you should thoroughly understand why and a better way of fixing it.
This section only goes over a limited set of information and should be complementary to a proper computer science education.
You may already be familiar with these, but don’t forget to use them!
In the same way – and for the same reason, objects placed in a scene (e.g. Mono Behaviours, UObjects, nodes, etc) should adhere to the Single Responsibility principle (SRP). For example, this means that you should not have one object called “Manager” with every single management script; instead, create a “Scene Manager,” “Audio Manager,” “Input Manager,” etc.
This is an important discussion to have when talking about most of the SOLID principles. According to this StackOverflow post:
When we talk about abstract classes we are defining characteristics of an object type; specifying what an object is. When we talk about an interface and define capabilities that we promise to provide, we are talking about establishing a contract about what the object can do.
On top of the considerations in the above thread, I rarely find the need for to use an abstract class and only encourage its use if you have shared behavior – most of the time this is not the case (e.g. an IComparable<T>
in C#).
TL;DR don’t use these ever. They violate the Dependency Inversion Principle. Instead, rely on Dependency Injection or static functions.
Not to be confused with just having a single instance – like having a static, singly-responsible manager class that is not depended on.
Writing tests allow you to ensure the project works as intended without relying on human error. Further, writing tests encourage SOLID principle conformity … because they’re largely impossible to write without them.
On writing tests, I highly recommend this GDC talk: https://www.youtube.com/watch?v=X673tOi8pU8, although I will add the most important test is that your game actually builds.
Tests should be written for code that has a clear definition of what a “correct” implementation looks like such as a math and physics library, or if the code is not expected to change. You should additionally implement a test whenever a bug is introduced to prevent regression bugs.
A good, three part article on test-driven development: https://gamesfromwithin.com/stepping-through-the-looking-glass-test-driven-game-development-part-1.
The results of the case studies indicate that the pre-release defect density of the four products decreased between 40% and 90% relative to similar projects that did not use the TDD practice. Subjectively, the teams experienced a 15–35% increase in initial development time after adopting TDD. https://www.microsoft.com/en-us/research/wp-content/uploads/2009/10/Realizing-Quality-Improvement-Through-Test-Driven-Development-Results-and-Experiences-of-Four-Industrial-Teams-nagappan_tdd.pdf
Test driven development does not appear to be useful to games that need to be produced within a short amount of time, or games that will not be maintained actively past shipping. Some people argue that this form of development is not helpful to game development due to its ever-changing nature.
Some articles from Riot Games on testing here.
In addition to automated tests, always use manual testing. Consider creating an isolated scene for each feature, which you can go and manually debug if something breaks. In this way, you can still effectively squash regression bugs. Compared to the alternative this can become less thorough and maintainable – especially in large codebases.
Tip | Information |
---|---|
Serialized Dictionaries | Serialized dictionaries do exist in the CoreRP library (docs); for whatever reason, they are not part of the Unity Engine core. |
Observer Scriptable Object Pattern | When using the Observer pattern, prefer to use Scriptable Object Event Channels, as it additionally allows for cross-scene communication. Keep in mind that C# events are an implementation of the Observer pattern and are to be used when no additional event handling behavior is used. |
Use OpenUPM over Git Packages | Packages maintained by a registry are better, in my opinon, because they can include third-party dependencies, are installed automatically, and overall I find the experience to be better, especially with their CLI. |
Use Awaitables over Coroutines | For versions of Unity after 2023 use Unity’s Awaitables. UniTask predates this solution. is supported on versions of Unity before 2023, and can be found here. |
Use Serializable Interfaces | Don’t use abstract monobehaviours because you can’t serialize an interface – in fact, with this package, you can. |
Use Animancer over Mecanim | Animancer is expensive and needs to be bought per seat but eliminates issues with Mecanim |
Tip | Information |
---|---|
Unreal Engine notes | https://ari.games/ |
Eryk’s Unreal Engine & GameDev Notes | https://rootkiller.pl/gamedev |