My past post was way too vague. Today I decided to shed the light on what actually I have come through.

CAUTION: This post may only be of interest for C# developers. Skip them if you don’t feel like a geek.

What’s up?

I didn’t limit myself to only game exit hooks. Instead, I decided to design a full-fledged, complex hook system which could benefit not only myself but any future mod developer.

From the very beginning, I immediately identified several unprecedented (at my boring enterprise full-time job) challenges. Below I mark specifically ✅ what is now resolved by the engine itself, and ❌ what is still a modder’s responsibility.

  • ✅ Hooks are not just procedures. They may or may not return some result.
  • ✅ Hooks have to work asynchronously, so that CLR could break dozens of subscribers to run in parallel.
  • ❌ One stuck callback handler wouldn’t freeze the entire game.
  • ✅ If a hook yields result, its invoker should be able to collect results from all callbacks and decide what to do.
  • ❌ Prevention of double-entry while a previous call is underway.
  • ✅ Hooks can be defined at runtime.
    • Hooks are not to be defined only by the core engine. Mods may step in role of subplatforms as well, and provide extensibility of mods by other mods.
  • ✅ Type safety is a must.

I explain below why I dropped some challenges.

Solution

“Hook” is a way too broad term. Actually, it comprises 2 completely different but glueable things:

  • Definition
    • Hook name
    • Type of result of a callback (or void)
    • Set of callback parameters with types
  • Actual callback
    • A method which other mods can subscribe (hang on hook) on a previously defined hook name

And I naturally introduced 3 different phases of a hook lifecycle.

  • Registration. This is where the core or a mod can define a hook, so that other parts of the running game could subscribe their custom callbacks.
  • Invocation. This is where the hook gets invoked.
  • Subscription. This is when another part of the product adds their custom callback method to a list of listeners for the registered (defined) hook.

Basically, invoker is the same entity as a registrant, because who else knows better what is this hook purpose and when it should be invoked? But from the coding perspective, registration and invocation are two different complex routines, that’s why I clearly separate them.

Let me show on a real sample how the code looks for every phase. For an example, I pick the new hook PreGameExitRequest.

Registration

delegate Task<bool> OnPreGameExitRequest(World world);

Hooks.Register(new HookDef(PreGameExitRequest, typeof(OnPreGameExitRequest)));

means:

  • Define a method signature (C# delegate type) named OnPreGameExitRequest:
    • the callback results bool,
    • accepts one parameter of type World.
  • Define a hook named PreGameExitRequest with the defined method signature OnPreGameExitRequest.

Invocation

Simple approach

List<bool> proceedConsents = await Hooks.InvokeAsync<bool>(PreGameExitRequest, exceptionSink: null, _world);

means:

  • call out every subscriber for the hook named PreGameExitRequest,
  • pass them the argument _world,
  • don’t care of exceptions (null in place of a so-called exceptions sink, details go below),
  • expect bool results.

Advanced approach

List<Task<bool>> tasks = Hooks.Invoke<bool>(PreGameExitRequest, exceptionSink: null, _world);

means same as the previous sample, but allows for more fine-grained async execution control.

Subscription

_world.Hooks.Subscribe(new HookSubscription {
    HookId = PreGameExitRequest,
    Delegate = (OnPreGameExitRequest)(_ => {
        _logger.LogInformation("Game exit requested: attempting to save config...");
        return _world.ConfigCoordinator.SaveConfig();    // SaveConfig() returns `Task`
    })
});

Exception handling

Invocation effectively means two things:

  • Creation of a Task object which would run the processing code.
  • Running the actual callback processing code inside the Task.

Either thing may fail and throw an exception.

To facilitate handling of exceptions, the engine provides an exception sink, and two different flavors of invocation (as mentioned above).

  • Invoke() is a low-level call. It calls callbacks, every of which should create a Task, and returns List of all Task-s gathered from subscribers.
    • Exception thrown at a Task creation (synchronous exception) gets caught by the engine and flushed into the exception sink which the invoker optionally provides.
    • Exception thrown by code running within a Task (asynchronous exception) is not handled, obviously. It’s up to the invoker to build up the necessary safeguards with Task.WhenAll()/catch AggregatedException or something else.
  • InvokeAsync() is a more convenient call. It internally calls Invoke(), but wraps await Task.WhenAll(), catches AggregatedException and flushed every InnerException into the sink, alongside with exceptions which might have thrown at Task creation. It only returns a List of already-consumed results extracted from the underlying Task-s.
    • Exceptions from both phases (synchronous Task creation and asynchronous execution) get into the sink.

What is the exception sink? Nothing more than… yet another callback! Invoker may supply a custom callback method to handle every exception flies. A callback accepts a pretty verbose context: what’s the subscriber, whether it’s an exception by creating of the Task or by inside code, what were parameters passed.

Supply of an exception sink (callback) is optional. The samples above clearly show that it may be null. So, if an invoker doesn’t care of a badly written unbeknown subscriber – it’s totally valid approach. None of Invoke() and InvokeAsync() throws an exception by itself just due to a subscriber doing so. Only exceptions thrown by the engine methods are bound to invalid conditions created by a caller. Exceptions from subscribers are unpredictable by the engine, so I decided tha my code must not rethrow them. Such exceptions are always managed via manual inspection of a returned Task and/or via the exception sink.

What about dropped items?

Let me elaborate them one by one.

❌ One stuck callback handler wouldn’t freeze the entire game

Problem: a subscriber may hang up and never yield a result.

Impact: hook invocation never goes through. No complete game freeze expected, but some hook-able functionality would definitely get broken. For example, exit game would never work if a bad subscriber hooks up.

Decision: the engine doesn’t care for now. Where it’s critical, an invoker may envisage extensive handling with timeouts thanks to the low-level Invoke() method provided by the engine.

While I acknowledge it’s a serious gap in reliability, I don’t want to overcomplicate things for the time being.

❌ Prevention of double-entry while a previous call is underway

Problem: as callbacks are processed asynchronously in non-blocking way by design, it may easily come that a player accidentally invokes them twice. For example:

  • Player attempts to exit game.
  • The engine calls subscribers to find out whether some mod want to prevent exit, prompt something, etc.
  • While some mod is processing the event, player clicks ‘Exit Game’ again.

Impact: depends on the invoker nature and hook purpose. Probably minor. For the example, it might lead to double prompts “Do you really want to save the corrupted config?”, nothing breaking.

Decision: I discharged the engine from bothering of such corner cases. The callback subscription process is designed in such a flexible way that a concerned mod may take care if needed. In most cases it would be an overkill, I bet.

Next plan

Stands the same: go on with actual XML mod loading stuff.

Stay tuned!