Getting Started with CSharp (Server-Side)

From Onset Developer Wiki

This article explains how and where you should start if you are either completely new to the Onset API or if you are coming from Lua. Let's be clear: this article does not explain how to program with C# and a good knowledge base is also required to work with the C# API, especially related to object-oriented programming.

Preparation

Requirements

  • A DedicatedServer for testing as well as integrating the libraries for programming should be prepared
  • .NET 5 (or newer) framework installed
  • An IDE (your choice; recommended VisualStudio or JetBrains Rider) installed
  • Minimum basic knowledge, recommended advanced knowledge of C#
  • A created project for a class library for .NET 5

Settings

By default the server ships the C# runtime with it. Currently we are using .NET 5.0.x. If you like to change the version used by the runtime, you need to download a new runtime, go to the corresponding runtime folder and extract the runtime there. Then you just need to change the runtime version to your new version by setting the option in the server config (see here: server config).

Add the Onset Library as Reference

Currently, installing and adding the reference is not yet supported through NUGET. Until then, the installation must be done by manually adding the DLL in the server folder. Just go into your Server-Folder and locate the dotnet folder in there. Now go into that folder and open the runtime folder in that folder. Now the path should be something like this:

~OnsetServer/dotnet/runtime

Now add the following two DLL files to your project as reference:

  • Onset.Server.dll
  • Onset.Shared.dll

Ideas and Concepts

This part of the article explains some ideas and concepts that were created before the actual API was programmed. These things are important to know in order to fully understand the API and the structure of it itself. For the actual programming with the API, however, this knowledge is not required.

Everything is an Entity

Actually, the concept of "Everything is an Entity" is not a new concept and certainly not C# exclusive, but due to the abstraction of the API (see Abstraction and OOP) this is a very important concept for C#. All things in the world that are not the world itself are entities, so players, doors, vehicles, other objects, pickups, 3D texts, and NPCs. Each of these objects has its own ID - which is not the same after a server restart - and functions that change this entity are executed with the ID.

Abstraction and OOP

Onset's Lua API is very simple in design, and that's fine for beginners and in general. But when it comes to more complex projects, the Lua API and Lua in general quickly comes to its limits, at least in relation to the neatness. Because the big problem of the Lua API: Each entity function requires entity IDs. In itself, this is good and important, but how do you manage them without it becoming complete chaos. For smaller projects this is still relatively easy. For massive projects it means a lot of work. The C# API was primarily created with the concept of object-oriented programming and abstracts the Lua API around just such objects. This creates a much more natural and realistic feel when you lay hands on development with Onset. In addition, the concept of "Everything is an Entity" comes into play here again. Each entity has its own class and each ID is assigned a C# object. Managing these objects is much easier. In addition, it saves a lot of code, because the functions of the entities can be called directly by this object, and thus the ID can be omitted from these functions.

JIT-Compiling and Pre-Compiled

The Onset API has two modes built in: JIT compilation as well as loading pre-compiled DLLs. JIT compilation is always active by default, unless you tell the server to load a pre-compiled DLL. If sandbox mode is enabled in the server, JIT compilation is forced and a kind of sandbox is also loaded that prohibits certain namespaces. However, there is no programming difference between JIT compilation or pre-compiled DLLs.

JIT-Compiling

JIT (Just in Time) compilation allows you to simply mark your classes as server files and let the server do the rest. The class files are automatically compiled and loaded at the end. This would allow scripts to be loaded and shared more easily and transparently. However, loading the server takes a little longer because the files have to be compiled first, of course.

Pre-Compiled

Using pre-compiled DLLs has some advantages. The biggest advantage is that this method allows you to use external libraries from NUGET or other sources. For server owners: when using this mode, please make sure that the things you install are legitimate and harmless. You can decompile the DLLs, but of course you have to do it yourself. To use the pre-compiled mode, you just have to go into the package.json of your project and add a line to the JSON object:

"use_csharp_assemblies": true

With that you can just add DLL files to your server files list in your package.json.

Main Class and Modules

One problem and weakness that C# brings with it is that files cannot simply contain code that is executed when initialized the way scripts can. This creates the problem that it needs a class and in this class a method which can then execute code. To counteract this problem, it needs a main class that follows a certain structure. This one main class is called when initializing, starting and stopping the package and executes the code that the Lua Script just so have in it. However, in order to maintain order, the C# API offers a Module API, which loads other classes with only one line of code to it, whereby parts from the main class can be outsourced.

Create a new Package

First you have to create a main class which must extend the Onset.Server.Package class. The package class forces you to override the OnStart() method which gets called when the package is getting started. The OnStop() method can be overridden optioanlly. The method is getting called when the package stops.

using Onset.Server;

public class Main : Package
{
    public override void OnStart()
    {
        // -- Put the Start Logic here -- \\
    }

    public override void OnStop()
    {
        // The overriding of the OnStop method is optional
        // and must be done manually
        
        // -- Put the Stop Logic here -- \\
    }
}

This is the absolute minimum needed for the package to be loaded and started. Now when the package gets started, the runtime calls the OnStart method.

Events

A very important part of the API are the events. Unlike the Lua API, this one also relies on OOP. Each event has its own object. In the object are then the parameters that the event brings with it. Cancelable events like the ClientConnectionRequest event also have a property called Cancel, which can be set to true to cancel the execution of the event and the subsequent actions. Objects make it easier to deal with events by eliminating the need for you to know what parameters the event brings with it. You only need to know the name of the object. You can use the auto-completion of your IDE for this. All events are located in the namespace Onset.Server.Events.*. In addition, the class names use the names of the events in Lua, but without the "On" and with an "Event" after the name. To listen to an event you just need to declare a method, the first and only parameter must be the wanted event and you need to mark the event as EventListener by adding the attribute.

[EventListener]
public void OnClientRequest(ClientConnectionRequestEvent e) 
{
    // -- Put Event Logic here -- \\
}

The main class as well as the module classes are automatically registered for event listening. If you use another class you need to register it manually with the following methods (these methods must be executed with an instance of Package because only there the EventManager is accessible):

// Registers all non-static methods in the class of the given object
EventManager.Register(new SomeEvents());
// Registers all static methods in the given class
EventManager.Register<SomeEvents>();

Commands

Unlike the Command API in Lua, that of C# offers some more functions and facilitations. The basic technique is similar to that of the events: A method marked as a handler takes over the processing of the command. The parameters of the method have a condition: In the first place must be the player, then follow the command parameters, which are freely selectable. The conversion of the types is taken over internally automatically. All basic types (strings, booleans, numbers), enums and entities from Onset are permitted. The name of the command is passed by the attribute above the handler. Names must be unique, otherwise an error is thrown.

[Command("welcome")]
public void OnWelcomeCommand(Player player, Player target)
{
    target.SendChatMessage("Hello! by " + player.Name);
}

Again, the registration in the main class and in the modules is done automatically. Other classes must be registered also here manually. The same principle applies as with the Events, only instead of the EventManager this takes over here the CommandManager.

Modules

As mentioned earlier, the Onset C# API provides a module API to outsource code and thus minimize clutter. Main class code can be moved to another file without losing functionality. There are 2 types of modules in C#: Automatic modules and Manual modules. In itself nothing changes between the two types of modules, the only change is related to the registration of the modules. The manual module, as the name suggests, must be registered manually, the automatic one is called automatically when the package is loaded AFTER the OnStart() method. Furthermore, an instance of the main class can be accessed in the module class via the Package property.

When should I use the manual module? When should I use the automatic module?

When to use what is very much based on your own preference. However, if you also take a look at the logical side, there is also a clear division: Use the manual module if a module has to be loaded flexibly, e.g. if the module should only be loaded if this module was also activated in a config. Otherwise always use the automatic one.

How to use Modules?

Each module, as already mentioned, is structured in the same way. Both modules must inherit from the Onset.Server.Module class, specifying the type of the main class as the generic type. After that the OnStart() method is overwritten.

using Onset.Server;

public class MyModule : Module<Main>
{
    public override void OnStart()
    {
        // -- Put the Module-Start Logic here -- \\
    }
}

Registration

Now only the registration is missing. For automatic modules only one attribute above the module class is needed:

using Onset.Server;

[AutoModule]
public class MyModule : Module<Main>
{
    public override void OnStart()
    {
        // -- Put the Module-Start Logic here -- \\
    }
}

If you want to use a manual module you need to register it manually in the main class, best at the end of the OnStart() method in the main class:

RegisterModule<MyModule, Main>();

The first generic type is the module class, followed by the main class. Please note that if you register the module manually, the OnStart() method of the module will be called immediately.

Interop: Call Lua in C# and vice versa

Interop or interoperating means to call one language from another language. In this case it means calling C# from Lua and Lua from C# - or calling C# from C#. But first there are a few things that need to be cleared up that are very important when interoping.

Lua <-> C# Types

The following table shows which types represent the other types of the other language. Only these types are allowed for interoping.

Lua Type .NET/C# Type
Integer (number) System.Int16 / int
Float (number) System.Double / double
Bool System.Boolean / bool
String System.String / string
Table System.Collections.Generic.Dictionary<object, object>

How does it work?

Of course, to use it properly, you should understand how interoping actually works. The actual process is very simple: A selected function is exported from C# (or Lua) and stored in the Interop Manager in the server. The exported function is identified by a predefined name and the name of the package from which the function originates. The function can then be imported by other packages via Package Name and Function Name and called at any time. Up to now only methods with one return value (or none) are allowed from C# sides.

Exporting a Function

Exporting a function is really simple and straightforward, especially if you already understand how commands are created in Onset C#. Just declare a method with return value (or void) and your parameters (the method MUST BE static) and mark it as an exportable function. In the attribute you need to enter a function name. After calling the ScanForExports<>() method, all functions in that given class will be exported:

[Export("sum")]
public static int Sum(int a, int b)
{
    return a + b;
}

Interop.ScanForExports<TheClassWithSum>();

Importing and using a Function

Importing and using a function is as simple as exporting it. In order to import the function you just need to call the Import<>() method in the interop API and define the target package and function name. The needed generic type is the return type. If the generic type is being omitted, the runtime will assume you want a void function instead. The returned delegate is from the type IInterop.ImportedFunction<T>, for simplicity just use the var keyword:

var sum = Interop.Import<int>("ThePackageWithSum", "sum");
var myResult = sum(1, 1);

Overriding Entity Factories

Overriding an entity factory is a really powerful technique which helps you to develop your package object oriented. The big problem of the native entity classes are, that you cannot store data on them without using the property value functionality. To solve this problem, there are entity factories implemented. You can write your own entity classes via extending the native ones, overriding the entity factories and the runtime will use your entity classes instead of the native classes. For example: You override the native player class, wrap your own class around it, override the specified entity factory and now you can create a command handler with your player class as first argument instead of the native one.

How to implement it?

The implementation of overriding entity factories is very simple and can be explained in a few steps:

Step 1: Extending the native entity class

public class MyVehicle : Vehicle
{
    public MyVehicle(int id) : base(id)
    {
        // Put your Entity Logic here
        Color = 0; // Every Vehicle will have the Color "0"
    }
}

Step 2: Create the entity factory

public class MyVehicleFactory : IEntityFactory<MyVehicle>
{
    public MyVehicle Create(int id)
    {
        return new (id);
    }
}

Step 3: Let Onset know, to use your custom factory instead (put this in the main class, or in a module but then you need to append Package)

Server.OverrideEntityFactory(new MyVehicleFactory());

Step 4: Use your custom entity

[EventListener]
public void OnVehicleDamaged(VehicleDamageEvent e)
{
    MyVehicle vehicle = (MyVehicle) e.Vehicle;
    // Use your vehicle here
}

Some Tricks

In this section we will explain a few little tricks that make programming with the C# Onset API easier. These "tricks" are actually topics in their own right, but creating a section for them would be a bit too much.

A function is missing which is in the Lua API?

Some functions didn't fit into any of the object-oriented classes so there is a Onset.Server.Util class which contains utilty methods or methods which haven't got any own classes.

Using predefined Enums for Constants

Constant values like the Vehicles or the PlayerBones got a predefined Enum in the Onset.* namespace. Instead of knowing the model IDs by heart, you can use the enum. Important: The method of the API does not allow the enums, you must cast the enum to the requested type to not encounter any errors.

Casting dimension IDs into a dimension object

In C# there is a Onset.Server.Dimension class which wraps around the unsigned integers (dimension IDs). With that you can cast these dimension objects to uints or uints into these dimension objects to help you at some point (like spawning vehicles directly into the given dimension). Please keep in mind: These dimension wrappers are using reflection to identify the right context which can influence the performance.

uint dimID = 1;
Dimension dim = (Dimension) dimID;
dim.CreateVehicle(...);

Using Debug Symbols to see Line Numbers in the Stacktrace

This trick is exculsively to the precompiled option because the JIT automatically generates debug symbols. When compiling a DLL in debug mode the compiler will automatically generate a so called PDB (debug symbols) file (if you haven't disable the option). When dragging the DLL file into the package folder, you can drag the PDB file, too in order to get line numbers in the stacktraces.

Adding external Libraries

To add and load external libraries, simply drag them into the package folder with the main library and drop them into package.json at "server_files". For simplicity a wildcard can be used: "*.dll". Keep in mind: This only works for precompiled mode!

Exporting NUGET Libraries to the Output Directory

This only applies to a normal C# project (*.csproj): Open the *.csproj with any editor. It should contain a so called PropertyGroup at the real beginning. Add the following line to that PropertyGroup:

<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

After saving the *.csproj and building the project, the NUGET libraries should be present in the output directory.