-
Notifications
You must be signed in to change notification settings - Fork 32
Technical Goals
This page outlining technical goals, why we aim for them and how to reach them.
The overarching goals are:
- cross-platform deterministism
- maintainability
- readability
- high-performance
Every in-game day goods are produced, wages are paid, events are fired and battles fought. This results in many data changes. For multiplayer, the simulation data must stay synchronised. Transmitting all changes every day is cumbersome and slow. Instead we merely transmit the actions taken by the human players. This requires the simulation to be fully deterministic so every instance produces the same result given the same input. As hardware, operation systems and even compilers can behave differently, the code is carefully written in a cross-platform deterministic way that uses a subset of operations guaranteed to produce the same results.
Floating point math is not cross-platform deterministic, integer math is. The simulation uses fixed point math instead of floating point math for this reason. Fixed points are essentially integers shifted by a hardcoded amount of precision bits. Note the UI (Godot project) is only local and may use floating points.
Goal: cache-locality (performance)
Store data directly in the owning type. Avoid smart pointers for this.
To handle missing values, use std::optional<T> or use the null object pattern.
Goal: prevent Use-After-Free bugs
When an item is moved to a different address, existing pointers to the old address become invalid.
Containers like std::vector<T> move elements when growing their capacity.
There are alternative container types that avoid this:
- array<T,SIZE> / C-array Official documentation requires a compile-time constant size
- memory::FixedVector Source requires runtime maximum capacity before construction
- plf::colony Official documentation requires insertion order is unimportant
Goal: minimal interface
Most methods only want to read or fill a container and not alter its size. std::span<T> official documentation is perfect for that. It provides access to a contiguous segment of values. You can alter the values with non-const spans. If you're just passing the data, use forwardable_span<T> (source) which supports forward declared types.
Goal: stable pointer from construction
Containers allow in-place construction of values via their emplace methods, for example std::vector::emplace_back. Just pass the constructor arguments to the emplace method and it constructs the value for you.
Goal: data integrity and performance
Most simulation types (countries, provinces, pops, etc) are both large and/or mutable. This makes copying them expensive and they miss changes to the data.
Instead use references T& name for both passing data and storing data. Only store types directly T name in the type owning the data, like MapInstance storing ProvinceInstances.
Goal: improved readability and preventing bugs
A number can represents many different concepts: mass, velocity, energy, an index, a year, etc. To improve clarity and prevent accidentally mixing concepts, different types are used to represent different things. Foonathan's type_safe is used for this, see his github.
For strongly-typed indices in the simulation, see source.
Goal: faster compilation
Headers should define what a type does (the interface), while the source file (.cpp) defines how it does it.
If a header file only needs to know that a type exists (for use in a pointer or reference), it should not #include the entire definition of that type. Including heavy headers inside other headers creates a web of dependencies where changing one file triggers a massive recompilation of the entire project.
Instead forward declare the type struct T; or class T;. You can pass references and pointers to the type, but you can't store it or access its methods.
Runtime Type Information (RTTI) often cause anti-patterns that hamper maintainable and scalable code. RTTI also increases the binary's size, build times, the memory allocated to the program on initialization, and when called costs cycles at runtime. While the GDExtension does not disable RTTI as godot-cpp relies upon it, being prohibited ensures there are no accidental uses of its behavior and when our code is compiled it does not include any RTTI costs.
Exceptions have a tendency to make understanding the program's flow difficult because of stack unwinding. Stack unwinding costs CPU cycles and with RTTI has an impact on build times, memory, cache, and binary size. Some functions even use exceptions as a means of error handling for legitimate inputs instead of an "exceptional" contract error. Error handling in C++ tends to be much more straightforward and most libraries implement support for disabling exceptions. (and the few that don't may be forked to implement it, it is often trivial work) Disabling exceptions forces the compiler to treat every function as if it has noexcept, if an exception were to be thrown beyond the library it would not trigger stack unwinding but should simply crash. noexcept is superfluous in our workflow.