Skip to content

Instantly share code, notes, and snippets.

@mrhelmut
Last active December 26, 2022 10:06
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mrhelmut/f0248bb7020a07447fad2b51d38a177c to your computer and use it in GitHub Desktop.
Save mrhelmut/f0248bb7020a07447fad2b51d38a177c to your computer and use it in GitHub Desktop.
Coding style for pure C# game development projects

Coding style

Over the years of porting pure C# games to consoles, we defined a set of coding guidelines which ensures that porting and performances go smooth on every possible platforms and C# runtimes.

The main reason for these guidelines is that C# runtimes on exotic platforms might be very outdated and/or limited in language features due to the very nature of using Ahead-of-Time (AOT) compilation, or even sometime going as far as transpiling IL Code to C++. Some runtimes are also extremely sensitive to garbage collections, which can produce very visible micro freezes during gameplay, hence our guidelines also target the limitation of garbage.

These guidelines will be very counter-intuitive to enterprise C# developers. Bear in mind that C# is a language that wasn't designed for games and performance in the first place, that using it in this context isn't effecient, and that pretty old C# runtimes mean that we have to bend the language to make it fit our needs. Also keep in mind that we can't count on modern .NET optimizations and we can't assume that everything behave just like it does on modern PC runtimes or your regular Unity runtime.

Ultimately, we could sum up our approach as "coding in C# like you would be coding in plain C".

Why still using C# in that context? Because it's a fantastic language that will always be more productive and readable than, let's say, C++ or Rust. Also, the .NET ecosystem is much more convenient to use and easy to deploy.

Limiting language features to C# 5.0

Runtimes we use are often limited to .NET 4.5. It is theoritecally possible to use modern C# language features while still targeting .NET 4.5, however you should be aware that some language features are not just syntactic sugars and are doing actual calls to assemblies which may not be available on older, or specific runtimes.

To simplify having to check every language features that we use, we enforce a limit to C# 5.0 directly in the project files (<LangVersion>5</LangVersion>).

We have good hopes that the runtimes requiring this will soon be a thing of the past, but for the time being we are bound to them.

No while loops

"while" loops are the roots of nearly all game freezes. They should be avoided at all cost because freezes are annoying to debug and having unexplainable freezes during console certifications or post-launch has been too frequently an issue that removing the root cause definitely made our days brighter.

You should try it, you'll be surprised that your code will never freeze again.

Always check for null and boundaries

Not checking for null or not checking arrays/collections boundaries before accessing something is the most popular crash source. From our experience, it's basically all of them. Yes, all.

Adding checks everywhere, even when you are already sure that a null or out of bound can't happen, does make code super robust and less error prone. Don't trust your own code.

No allocation at runtime

We want to remove all pressure from the garbage collector, hence the idea is to completely avoid memory allocations during gameplay.

This means that using the "new" keyword on non-value types from inside the game loop is to be avoided. This also includes tracking hidden allocations from external assemblies (we tend to avoid using external dependencies because of that and reflection).

To manage the need of dynamic objects, we favor the use of memory pools. In example, if we need a particle system, we use a fixed-size circular buffer of pre-allocated particles (that we allocate during a loading screen) that we grab when needed and return to the pool once inactive.

The main idea is to keep active pointers to inactive objects so that they are not flagged as garbage and adding pressure to the garbage collector.

We are entirely allocation-free and framerates are butter smooth.

No foreach loops

On some older runtimes, foreach loops tend to generate garbage, we want to avoid that at all cost.

It is less true on modern .NET, but since we can't count on that, let's just skip foreach.

No generic collections

Because collections might have hidden allocations and dependencies, we prefer to avoid them (unless we can fully predict their behavior).

It is also to be noted that due to the varied, and old runtimes that we rely on, we can't trust collections to have a consistant behavior and performances.

We like to use arrays. Arrays are nice and predictable.

No Reflection

Reflection is a total no go in full AOT environments. It simply won't work.

It is also important to check if any external dependencies rely on it (e.g. bye bye NewtonSoft.JSON).

No LINQ

LINQ is a garbage feast. Let's just skip entirely on it.

No plain text serialization/deserialization

Plain text manipulations (e.g. XML, JSON...) are extremely slow and garbage-prone.

We are fine with using plain text formats in our development tools to ease manipulating data, but once a project is built in release mode, all data shall be in optimized binary formats to ensure performance and memory footprint.

Things like protobuff are great for that purpose.

Also, removing plain text manipulations is gain of performance to an order of magnitude of x1000.

Avoiding inheritence and designing independent systems

Because some runtimes involve C++ transpilation, build times may skyrocket if classes have too many circular dependencies.

To avoid that, we design with an absolute minimum of inheritence. We prefer to use interfaces, and we design all of our systems to be independent singletons. It's also much more readable than inheritence noodle sacks.

You may see this as coding like you would code in C.

All system accesses shall be considered asynchronous

System accesses (e.g. save data, achievements, user login...) should be considered to be asynchronous calls.

Even though these might be synchronous on some platforms, or fast enough to be awaited, it is preferred to consider them asynchronous nonetheless.

This is because some platforms will definitely be asynchronous with huge waiting times.

@ripwin
Copy link

ripwin commented Dec 25, 2022

Thank you very much for these guidelines! But it's wild. It means that we have to do everything from scratch? Is there any library out there that we can use safely (except Monogame)? For example, DefaultECS is an awesome library to do Entity Component System. But it uses foreach, generic collection etc... And these rules are still relevant with .NET7 ?

@mrhelmut
Copy link
Author

Beside the C#5 limitation, everything else is still very much valid as of .NET7. But these are not strict, there's a bunch of situations where using generic collections and foreach makes sense and doesn't contradict the no-garbage guideline.

And these are not rules, just guidelines to attempt smoothing out porting C# games to consoles. If it's your first game, I wouldn't worry much about that (unless it's fun to you to try to follow guidelines) and focus on the game and shipping it before thinking about optimization.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment