Prerequisite Knowledge

Understand the core systems about the viewer.

Introduction - Profiles

All the source code for the viewer can be found in path_to_viewer/resources/app .

In the majority of cases this code is not relevant, and we are only interested in the profiles which the viewer loads and uses for parsing and rendering replays. These can be found in path_to_viewer/resources/app/assets/profiles where each folder here represents a separate profile.

Out of the box you should find 4 profiles:

  • vanilla - This is the base profile that gets automatically loaded and is used for handling vanilla gameplay.

  • template - A template profile which can be copied and pasted for creating new profiles. By default loads vanilla.

  • duo trials - Profile for the custom rundown Duo Trials.

  • descent - Profile for the custom rundown Descent.

When a profile is loaded, all the JavaScript files within the profile folder is executed and will be watched for hot reloading via a system called the Async Script Loader (ASL):

Async Script Loader (ASL)

Understanding of ASL requires decent knowledge of JavaScript ES6 Modules and JavaScript Promises. If you are not making complex script changes then feel free to skip ahead. Sections that require knowledge about ASL will link back to this page.

ASL is the core system that handles the dynamic loading of scripts, it is used over the existing JavaScript module system, ES6 Modules, to support hot reloading for the ease of use for developers.

Since it does not rely on ES6 Modules, it has its own syntax for performing corresponding actions. Lets take a look at a simple ASL module and its ES6 Modules counterpart:

const { Vector3 } = await require("three", "esm");
const { foo } = await require("./B.js", "asl");

exports.bar = function() {
    return foo(new Vector3(1, 2, 3));
}

The ES6 Modules counterpart:

import { Vector3 } from "three";
import { foo } from "./B.js";

export function bar() {
    return foo(new Vector3(1, 2, 3));
}

As you can see there are not many differences between ES6 Modules and ASL apart from the syntax.

Async

ASL modules all execute in an Asynchronous context. Under the hood, this is done via an eval() call within an async function :

(async function() { /* Your Module Code ... */ })()

This allows for the use of the await keyword to be used which is useful with async methods that fetch content such as models / other ASL modules.

Importing Modules

To import other modules you use the function require :

function require(path: string, type: "esm" | "asl" = "asl"): Promise<any>;

require accepts a path to the module you wish to import and an optional parameter to specify the import type. If no import type is specified, "asl" is assumed by default.

If the path provided is an absolute path (does not start with . ) then it will load the path relative to the baseURI of the page. This will be from path_to_viewer/resources/app/assets/main .

For example, the following code will import the module located in path_to_viewer/resources/app/assets/main/B.js :

require("B.js");

Relative paths are resolved as expected, importing the module relative ot the current module.


Sometimes you may wish to import regular ES6 Modules and not just ASL modules, this is where the type parameter comes in. By default, it is assumed that you are loading an ASL module, however if you specify "esm" , you can import regular ES6 modules.

Importing ES6 modules follows the same path resolution as if you were using the import statement. This includes aliases specified by the importmap .

For example, the viewer frequently uses the THREE JS library which is an ES6 module:

const { Vector3 } = await require("three", "esm");

require returns a Promise which resolves once a module finishes importing. Once resolved, it returns an object containing the exports of the imported module.

Because it returns a Promise, and modules execute within an Async context, you can await a require call:

const B = await require("./B.js");

Exports

ASL supports exporting items from one module such that they are accessible from another. This is done using the exports syntax:

// Module A
exports.foo = function() {
    console.log("Hello from A");
}

exports.bar = 1;
// Module B
const { foo, bar } = await require("./A.js");

foo(); // "Hello from A"
console.log(bar); // 1

Unlike ES6 module exports, features such as "default exports" or "aggregation" are not supported. ASL exports are more akin to using the this keyword to assign properties to an instance.

Because of this, care needs to be taken to ensure the correct behaviour when changing non-referenced values:

// Module A
let foo = 1;
exports.foo = foo;
foo = 2;
// As foo is not an object, it gets copied and thus changing foo
// does not change exports.foo

exports.bar = 1;
exports.bar = 2;
// Module B
const { foo, bar } = await require("./A.js");

console.log(foo); // 1
console.log(bar); // 2

Further more, exports are immutable. The decision to do so is to prevent reference gymnastics with import syntax:

let { foo } = await require("./A.js");

foo = 10; // This does not actually change the value of `foo` in module `A`, 
          // due to how javascript object decomposition works.
          
const A = await require("./A.js");

A.foo = 10; // Although this would work, the syntax is a gotcha that is annoying
            // to account for when mixed with decomposition syntax and is just
            // a source of bugs in the event you forget about this behaviour.
            // 
            // As a result, I don't allow this behaviour at all and ASL
            // will throw an Error.

Circular Dependencies

ES6 will still load modules even if there are circular dependencies which can cause bugs due to an export not yet being available within said loop.

ASL, on the other hand, simply will continue execution until a module has fully loaded. This means that in the event of a circular dependency, the module will not complete execution:

// Module A
exports.A = 1;

console.log("waiting for B"); // "waiting for B"
const { B } = await require("./B.js");

console.log(exports.A + B); // does not get reached
// Module B
exports.B = 2;

console.log("waiting for A"); // "waiting for A"
const { A } = await require("./A.js");

console.log(A + exports.B); // does not get reached

In the above case, both modules will pause on there await require() after logging waiting for A and waiting for B . This is because require cannot import either module until both have completed, but as both depend on each other, this will never happen.


To get around this, ASL provides a the method module.ready() which tells ASL that all exports are finished and the module is ready for use even though it has not yet finished executing.

// Module A
exports.A = 1;
module.ready();

console.log("waiting for B"); // "waiting for B"
const { B } = await require("./B.js");

console.log(exports.A + B); // 3
// Module B
exports.B = 2;
module.ready();

console.log("waiting for A"); // "waiting for A"
const { A } = await require("./A.js");

console.log(A + exports.B); // 3

Circular dependencies can be debugged in the electron browser developer tools and typing vm.requires . This will output a Map where the keys represent modules that are waiting and the values specify which modules they are waiting for.

Destructor

As ASL provides hot reloading support, modules need to manage being unloaded to free resources and prevent memory leaks. To do this, you can assign a destructor which gets called when a module is unloaded:

module.destructor = function() {
    console.log("This module is being destructed!");
};

Error Handling

ASL tries to provide as helpful error messages as possibly:

Verbose error message provided by ASL

However, sometimes this is not possible as the error is not caught by ASL such as in the case of Promises. In these cases, for better error handling, you can catch the error and throw it again throw module.raise():

promise.catch((error) => throw module.error(error));

This will wrap the error in a verbose message as shown above.

You may also include an additional message to the error to provide more context, this will be logged along side the actual error:

promise.catch((error) => throw module.error(error, "Context"));

Memory Leaks

Due to the nature of hot reloading and switching profiles, it can be very easy to leak memory.

JavaScript is a garbage collected language which means that memory is freed once all references to objects are lost. The issue is that there are a lot of hidden references that are held by the browser which can stop modules from being properly garbage collected after hot reloading / changing profile.

The above module will leak as it can never be garbage collected since the element on the browser will continue to hold a reference to it via the event listener.

A simple fix can be achieved by clearing the event listener on module destruction.

A useful pattern for this is the AbortController pattern which allows you to pass an abort signal to each event listener such that it automatically is cleaned on destruction:

To simply the above, ASL provides the abort signal for you in module.disposesuch that you do not need to create one yourself:

let A = 0;
exports.A = () => A;

const element = document.getElementById("some-element");
element.addEventListener("click", () => {
    ++A;
    element.innerHTML = `${exports.A()}`;
}, { signal: module.dispose }); // module.dispose signal is triggered after
                                // module destructor automatically

Despite this, the use of AbortController is still useful to be aware of in the event you want to dispose of event listeners manually.

Additional Helpers

ASL provides additional helper functions:

  • module.src - provides the path to the current module file.

  • module.rel(path: string) - converts a relative path from this module to an absolute path relative to the baseURI. This is useful when targeting relative files for fetch or model loaders such as GLTFLoader .

Last updated