Contributing code patterns - rebaserHEAD/NexusForever GitHub Wiki
The structural habits of upstream's world-side code: which project a thing goes in, how it's registered, and the shape each kind of class takes. The step-by-step path for one per-character feature is on Adding a player feature; this page is the rulebook behind it. Where an old and a new pattern sit side by side, the tables say which one new code follows. Line-level style is on Contributing code style.
Project references run one way, from pure data up to the host. Content scripts sit beside Game, seeing only Game.Abstract and Script.
flowchart TD
S[Game.Static: enums] --> D[GameTable, Network.World, Database.*]
D --> A[Game.Abstract: interfaces]
A --> G[Game and Script]
G --> W[WorldServer: host]
| Thing | Goes in |
|---|---|
| Enums and constants |
Game.Static/<Area>/, one per file. Network.World/Message/Static is being emptied into it |
| Interfaces for game objects and managers |
Game.Abstract, at the same relative path as the class in Game
|
| Message models | Network.World/Message/Model/<Area>/ |
| Message handlers | WorldServer/Network/Message/Handler/<Area>/ |
| Config classes | <Project>/Configuration/Model/<Name>Config.cs |
| Content scripts |
Script.<Area>/<Zone>/, such as Script.Alizar or Script.Instance
|
Game.Abstract is a contract layer, but not a pure one: its interfaces extend the database save interfaces, and some of them return message models.
Package versions live in Source/Directory.Packages.props, and a project file names a package without a version. The maintainer updates them in batches.
Every folder with services has its own ServiceCollectionExtensions.cs holding one method, Add<Project><Folder>(this IServiceCollection sc), called from its parent folder's. AddGameMatchingMatch lives in Game/Matching/Match/ and is called from AddGameMatching.
| Helper | Registers | Use it for |
|---|---|---|
AddSingleton, AddTransient |
The usual | New services |
AddSingletonLegacy<I, T> |
The class and its interface as one object | A Singleton<T> class, so .Instance and injection share it |
AddTransientFactory<I, T> |
The class and an IFactory<I>
|
Objects made on demand: Resolve(), then Initialise(...)
|
IFactoryInterface<T> |
A factory that resolves a concrete type | Choosing the type at run time: maps, matches, movement commands |
AddRebusHandler |
A broker message handler | Messages from the other services |
| Old | New | |
|---|---|---|
| A global manager | Derives Singleton<T>, read through .Instance
|
A plain class registered with AddSingleton, injected where it's used and initialised from HostedService
|
| A per-character manager |
new XManager(this, model) in Player.Initialise
|
Registered with AddTransient, injected into Player's constructor, then .Initialise(this, model)
|
| A logger | NLog's LogManager.GetCurrentClassLogger() in a static field |
ILogger<T>, injected |
| Config | SharedConfiguration.Instance.Get<T>() |
IOptions<T>, injected |
MatchManager, ChatFormatManager and PublicEventTemplateManager are the new shape of a global manager. CurrencyManager and GuildManager are the new shape of a per-character one.
Important
Command categories are created with Activator.CreateInstance, so they can't take constructor parameters. They reach services through .Instance.
A global manager is wired into WorldServer/Service/HostedService.cs in up to three places: its Initialise in the start-up list, which is ordered and says so in comments where it matters; its Update in the tick; and its save on shutdown.
| Rule | Note |
|---|---|
[Message(GameMessageOpcode.X)] on the class |
No registration; models are found by reflection |
Client* implements IReadable, with { get; private set; }
|
Reviewers flag a public setter |
Server* implements IWritable, with { get; set; }
|
|
| Fields in wire order | |
| Every width written out |
writer.Write(Id, 14u), reader.ReadEnum<Result>(5u)
|
Lists as List<T> Items { get; } = []; |
Write the count, then each item |
Identity for a character |
The realm and character id together, not a bare ulong
|
| No logic | Beyond choosing which branch of a union to write |
Unknown fields as Unknown0, Unknown1, ... |
UnusedN for a field known to be dead. Renamed when someone works out what it holds |
Warning
The enum, ulong, long and double overloads of Write and Read default to 64 bits. Leave the width off an enum and it compiles, then writes 64 bits where the client reads a few, and every field after it lands in the wrong place.
GameMessageOpcode, in Network/Message/, holds every opcode for both the auth and the world server. Values are four hex digits, sorted, with the = aligned. An opcode nobody has identified yet is named after its value, such as Server0635.
| Rule | Note |
|---|---|
One class per message, <Message>Handler : IMessageHandler<IWorldSession, <Message>>
|
Found by reflection |
| Services through the constructor | In the dependency injection region |
session for the session parameter |
|
An impossible value throws InvalidPacketValueException |
The session logs it and disconnects the client. Any other exception is logged and swallowed |
Upstream doesn't require a handler to hand its work to a manager, and some carry real logic. A thin handler that calls a manager on session.Player is still the easier review.
Broker handlers are a separate path: IHandleMessages<T>, registered with AddRebusHandler and subscribed in NetworkInternalHandlerHostedService. Leaving out either one still compiles.
Socket threads queue each packet, and the world update drains every session's queue in order, so message handlers run on the update. Maps update in parallel by default; MapConfig.SynchronousUpdate turns that off. Broker handlers run on the broker's threads.
| Rule | Note |
|---|---|
No async in game code |
|
| A database task comes back as an event |
session.Events.EnqueueEvent(new TaskEvent(task, callback)). The callback runs on the update |
Other threads go through SynchroniseAsync |
On the entity |
Timers are UpdateTimer |
Ticked from Update(double lastTick)
|
| Broker publishes don't wait | .FireAndForgetAsync() |
| Rule | Note |
|---|---|
| One enum per saved class, nested in it |
<Type>SaveMask or SaveMask
|
None, Create, sometimes Delete, then one bit per saved column |
In hex |
Reset to None after loading |
Otherwise every loaded row is written back |
Title.Save is the model for the create and update paths, stock comments included:
- Nothing changed: return.
-
Create:
context.Addthe whole model. -
Delete, where the class supports it: a model holding only the key, with its state set to
EntityState.Deleted. -
Otherwise:
context.Attacha key-only model, then setIsModifiedon each column whose bit is set. - Reset the mask.
The interfaces are IDatabaseCharacter, with Save(CharacterContext), and IDatabaseAuth, with Save(AuthContext). IDatabaseState adds PendingCreate and PendingDelete. Saves on the world side run without an explicit transaction.
Which database owns what, and how tables and columns are named, is on Databases. Migrations are EF Core code-first, generated by the tools and named yyyyMMddHHmmss_PascalName. A new database's first migration is HelloWorld.
| Rule | Note |
|---|---|
[GameData] on every table the game reads |
Without it the table never loads |
A new table goes on both GameTableManager and IGameTableManager |
|
GetEntry returns null for an unknown id |
A handler throws InvalidPacketValueException, a command replies and returns, a load from the database throws DatabaseDataException
|
| Text goes to the client as ids | The server rarely reads a text table itself |
How tables load is on Game tables.
| Rule | Note |
|---|---|
[Command(Permission.X, "help", "name")] on the category and on each method |
Methods are Handle<Category><Action>(ICommandContext context, ...)
|
[Parameter("help")] on each argument, on its own line |
With ParameterFlags.Optional or a converter type where needed |
| Subcategories are nested classes | |
Replies through context.SendMessage |
SendError exists but is rarely used |
| Permissions explicitly numbered |
Permission.cs says so at the top, and lists retired ids to reuse first. Grouped by category, the parent first, then <Category><Action>
|
A new permission also needs its row in the auth database (Adding a player feature).
| Rule | Note |
|---|---|
One map script per zone, <Zone>MapScript.cs
|
|
| Bound with a filter attribute |
[ScriptFilterOwnerId(...)] for a world, [ScriptFilterCreatureId(...)] for a creature |
| Dependencies through the constructor |
Game.Abstract types only |
| Ids become a local enum | Not bare numbers |
Patterns in the code not to copy:
- A few services are registered twice, and the second registration wins.
- Three folders under
Game/Entityspell their fileServiceCollectionExtentions.cs. - Some handlers kept their old name when their message was renamed.
| Piece | Path |
|---|---|
| Dependency injection helpers | Source/NexusForever.Shared/ServiceCollectionExtensions.cs |
| Start-up, tick and shutdown wiring | Source/NexusForever.WorldServer/Service/HostedService.cs |
| Opcodes | Source/NexusForever.Network/Message/GameMessageOpcode.cs |
| Packet reader and writer |
Source/NexusForever.Network/GamePacketReader.cs, GamePacketWriter.cs
|
| The session's packet loop | Source/NexusForever.Network/Session/GameSession.cs |
| Map updates | Source/NexusForever.Game/Map/MapManager.cs |
| The model save | Source/NexusForever.Game/Entity/Title.cs |
| Permissions | Source/NexusForever.Game.Static/RBAC/Permission.cs |
| Broker subscriptions | Source/NexusForever.WorldServer/Network/Internal/Handler/NetworkInternalHandlerHostedService.cs |
| Task events | Source/NexusForever.Shared/Game/Events/TaskEvent.cs |