Skip to content

Custom System Parameters

Pedro Clerici edited this page Jul 26, 2025 · 1 revision

Thyseus' system parameters are designed so that it is simple to write your own. Parameters are responsible for declaring how they interact with other parameters and what data they need from the world - as a result, just about anything Thyseus can do internally, you can do, too! System parameters start with system parameter descriptors.

What is a system parameter descriptor?

As far as Thyseus is concerned, a system parameter descriptor - the items in the yourSystem.parameters array - is simply an object that contains the following four methods:

  • isLocalToThread(): boolean
  • intersectsWith(other: object): boolean
  • onAddSystem(builder: WorldBuilder): void
  • intoArgument(world: World): any

We'll dive into each of these methods and what they do before getting into how to write our own descriptors.

isLocalToThread(): boolean

This determines whether this parameter may only be accessed from the main thread. Descriptors should return true if the parameter is only available from the main thread, and false if the parameter is available on any thread. Usually descriptors will either always return true or always return false, but sometimes this is dependent on the provided data!

intersectsWith(other: object): boolean

This method is called with other parameter descriptors, and is used to determine if other parameters intersect with or are disjoint with this parameter. If parameters intersect (true is returned), the systems they belong to will be marked as intersecting, and cannot run in parallel. If they are disjoint (false is returned), the systems may run in parallel.

intersectsWith does not need to be symmetric; both descriptors are called with each other, and if either returns true they are marked as intersecting.

onAddSystem(builder: WorldBuilder): void

This method is called when a system is added by a WorldBuilder instance. It is called with the WorldBuilder that added it, and allows the descriptor to do whatever it may need with the builder - anything from adding plugins to registering types. For some descriptors (e.g., WorldDescriptor), this function is a no-op.

intoArgument(world: World): any

This method is passed the world building the system, and whatever is returned from this method will be passed to systems when they are called. This method may return anything.

Note

Promises returned by intoArgument(world: World): any will be awaited before systems are called!

Recreating Res

Let's dive into the internal implementation of ResourceDescriptor to get a feel for how these methods might work.

Construction

First, we'll need a class that creates our descriptor objects. For parameter descriptors to work with the transformer, parameter descriptors must be implemented as classes, as the transformer will try to new them.

To get started with resources, we really just need to know the type (or class) of the resource:

class ResourceDescriptor {
	resource: Class;
	constructor(resource: Class) {
		this.resource = resource;
	}
}

That's a nice start! Let's try adding some descriptor methods.

onAddSystem for Resources

First, we need to know what the builder should do when it comes across this descriptor. Resources in Thyseus are a first-class concept, so all we'll need to do is register that we have a (potentially) new resource!

class ResourceDescriptor {
	onAddSystem(builder: WorldBuilder) {
		builder.registerResource(this.resource);
	}
}

The builder handles deduping types, so that's it for our responsibilities. Other descriptors may need to add systems to work correctly, or to register components, or more.

Resource Thread Locality

Resources in Thyseus are allowed to be either mainthread-only or shareable across threads. Thyseus allows shared data with Structs; if a class is correctly implemented as a Struct, then we should be able to share it across threads. If not, then we'll only be able to access the data from the main thread.

So how do we know a class is a struct? Well, there's an internal utility for this:

function isStruct(val: unknown): val is Struct {
	return (
		typeof val === 'function' &&
		typeof val.size === 'number' &&
		typeof val.alignment === 'number'
	);
}

Structs are very simple, from Thyseus' perspective - classes (functions) with static size and alignment properties.

Okay! Now to use this in our descriptor class:

class ResourceDescriptor {
	// ...

	isLocalToThread() {
		// Remember - this method asks if the parameter _IS_ local to the thread!
		// If our resource IS a struct, then it is NOT local to the thread
		// If it is NOT a struct, then it IS local to the thread.
		return !isStruct(this.resource);
	}
}

Parameter intersection

Now we've made it to a trickier concept - parameter intersection. We need to know if parameters intersect to determine whether systems may run in parallel. In general, it is safe for two (or more) systems to read the same data at the same time. However, if a system writes data, it is no longer safe for a different system to read or write that data.

But wait - we don't even know if we're reading or writing data! We'll need to revise our constructor a little bit to make sure we know what we're doing with the data we access.

There's a number of approaches we could take here. One would be to accept a second argument, perhaps a boolean indicating whether our data should be readonly or not. Another would be to split our descriptor into two, perhaps a ResourceDescriptor and MutableResourceDescriptor. Either of these would be a perfectly valid approach - however, Thyseus already includes a Mut<T> utility for queries to denote whether they access a component mutably or not. For the sake of a consistent API, we'll mirror that for resources. Our new constructor then might look like this:

class ResourceDescriptor {
	resource: Class;
	canWrite: boolean;

	constructor(resource: Class | Mut<any>) {
		// Mut is a class, so we can use instanceof
		const isMut = resource instanceof Mut;
		// Mut instances just have a value property, containing the wrapped class.
		this.resource = isMut ? resource.value : resource;
		this.canWrite = isMut;
	}
}

Great! Now we know if we're reading or writing data.

For intersectsWith, we really don't know anything about the values we'll receive. It could be a first-party parameter descriptor, or a different third party descriptor. Internally, we generally choose to treat these as unknown, but since they'll always be descriptors, you could type them as object (or even a slightly more narrow Descriptor type).

So, first we need to know if the other descriptor is a resource descriptor. If it isn't, there's no risk of intersection - or strictly speaking, we will place the responsibility of determining this intersection on the other descriptor. Next, we only run the risk of intersection if we're accessing the same data. And last, we only intersect if either this descriptor or the other is accessing data mutably. In code:

class ResourceDescriptor {
	intersectsWith(other: unknown) {
		if (!(other instanceof ResourceDescriptor)) {
			// No intersection if it's not a resource.
			return false;
		}
		if (this.resource !== other.resource) {
			// Also no intersection if we're not accessing the same resource.
			return false;
		}
		if (!this.canWrite && !other.canWrite) {
			// No intersection if neither needs to write data!
			return false;
		}
		// One of us writes data the other needs to read/write.
		// That's an intersection!
		return true;
	}
}

Remember that intersectsWith does not need to be symmetric. Both descriptors will call this method with the other, and if either returns true, then the parameters intersect. For example, the WorldDescriptor implements this method like so:

class WorldDescriptor {
	intersectsWith(other: unknown) {
		return true;
	}
}

Because we trust that WorldDescriptor correctly implements its own intersectsWith method (it always intersects), ResourceDescriptor doesn't need to concern itself with how it interacts with WorldDescriptor or any other descriptor.

Turning Parameters Into Arguments

The last step is turning your parameter descriptor into an argument. For this, we receive the world as an argument, and will usually return some piece of its internal state. In the case of resources, it's pretty easy to get that data!

class ResourceDescriptor {
	intoArgument(world: World) {
		return world.getResource(this.resource);
	}
}

And perfect - you now have a fully functioning custom system parameter! Here's the full code:

class ResourceDescriptor<T extends Class | Mut<Class>> {
	resource: Class;
	canWrite: boolean;

	constructor(resource: T) {
		const isMut = resource instanceof Mut;
		this.resource = isMut ? resource.value : resource;
		this.canWrite = isMut;
	}

	onAddSystem(builder: WorldBuilder) {
		builder.registerResource(this.resource);
	}

	isLocalToThread() {
		return !isStruct(this.resource);
	}

	intersectsWith(other: unknown) {
		if (!(other instanceof ResourceDescriptor)) {
			return false;
		}
		if (this.resource !== other.resource) {
			return false;
		}
		if (!this.canWrite && !other.canWrite) {
			return false;
		}
		return true;
	}

	intoArgument(
		world: World,
	): T extends Mut<infer X>
		? X
		: Readonly<InstanceType<T extends Class ? T : never>> {
		return world.resources.get(this.resource);
	}
}

Next, we'll go into how to let the transformer recognize your parameter.

Transforming System Parameters

The transformer plugin accepts config that allows you to register additional system parameter types for it to recognize. Using the config, we can tell the transformer how to handle our custom system parameters.

type ThyseusPluginConfig = {
	systemParameters: SystemParameterMap;
};
type SystemParameterMap = Record<string, SystemParameterDescriptor>;
type SystemParameterDescriptor = { descriptorName: string; importPath: string };

The key in the SystemParameterMap refers to the name of the type we'd like the transformer to recognize. The descriptorName is the named import it will be imported as. importPath is the location to import it from. For system parameters to work correctly, they must be able to be imported from a non-relative path; how you handle this is dependent on your bundler and how you'd like to handle import aliasing.

For the case of Res, this looks something like:

import { defineConfig } from 'vite';
import { thyseusPlugin } from '@thyseus/rollup-plugin-thyseus';

export default defineConfig({
	plugins: [
		thyseusPlugin({
			systemParameters: {
				Res: {
					descriptorName: 'ResourceDescriptor',
					importPath: 'thyseus/descriptors',
				},
			},
		}),
	],
});

We tell the plugin that we'd like it to recognize types named Res, and that it will need to import { ResourceDescriptor } from 'thyseus/descriptors'; in order to use that system parameter. When the transformer sees a system with that type, it'll generate the necessary code to make your systems work.

// This input...
function mySystem(res: Res) {}

// ...will be transformed into
import { ResourceDescriptor } from 'thyseus/descriptors';

function mySystem(res: Res) {}
mySystem.parameters = [new ResourceDescriptor()];

You may have noticed that Res as provided by Thyseus is generic - in fact, it has to be in order to work correctly! When system parameters accept generics, those are passed as arguments to the parameter descriptor function.

// === Untransformed code
class MyResource {}
function mySystem(res: Res<MyResource>) {}

// === Transformed!
import { ResourceDescriptor } from 'thyseus/descriptors';

class MyResource {}

function mySystem(res: Res<MyResource>) {}
mySystem.parameters = [ResourceDescriptor(MyResource)];

It is up to you to validate that the types provided to your system parameter and the arguments provided to your descriptor are correct, both at the type level and at runtime.

Clone this wiki locally