How scripts are found
Adding a component to a game normally means writing the class, importing it somewhere, adding it to an array the engine receives, and separately telling the editor it exists. Four steps. Three of them are clerical, and each one is easy to forget.
The convention on this page removes all of them. A file with the right name is collected into the game automatically, and the decorator on the class describes it to the editor.
Nothing here is built into the engine
Section titled “Nothing here is built into the engine”The engine takes arrays of classes. It has no glob, no file scanner, and no knowledge of your directory layout. What collects the files is your project’s own entry point, in a few lines you can read and change.
The editor is no different. It compiles your project with its own bundler, driven by regular expressions you can edit. It imports every file that matches and reads what each file exports. Every exported class with a decorator appears in the editor.
So what follows is a convention, not a feature. It is worth keeping because the editor’s script generation writes files that follow it, but it is yours.
The naming convention
Section titled “The naming convention”Two things make a file part of your project.
Its name ends with a known suffix:
| Suffix | Declares |
|---|---|
*.component.ts |
A component |
*.system.ts |
A system |
*.behavior.ts |
A behavior |
*.shader.ts |
A shader |
*.filter-effect.ts |
A filter effect |
*.asset.ts |
An asset kind |
*.widget.tsx |
An inspector widget |
The editor configuration holds these patterns in seven settings, one for each suffix. You can
change every one of them. The widget setting also matches .ts, .js and .jsx.
Your game’s entry point keeps its own list. The starter project globs components, systems and behaviors, because that is what most games use. The rest are collected the same way when you need them: one more glob, written like the three that are already there.
The widget is the one file that never reaches the game. It draws a component’s fields in the inspector, so it is editor code. See custom widgets.
The class is the default export:
export default class Health extends Component { /* … */ }A file named health.ts is not collected at all. A named export is worse, because the two
sides disagree. The editor finds the class among the file’s exports, so the component appears
in the inspector. Your game’s glob reads the default export, finds nothing, and the class never
reaches the engine. A class that is not exported at all reaches neither side. There is no
build error in any of these cases. A widget file is the exception. It registers itself when it
is imported and needs no export at all.
Two files found by their path
Section titled “Two files found by their path”Two parts of a project are located by path rather than by suffix:
| Path | Holds |
|---|---|
src/events/index.ts |
Your event names, so the editor can offer them where an event has to be chosen |
src/locales/index.ts |
Translations for your field titles |
Both paths are settings in the editor configuration, like every pattern in the table above.
Collecting the files
Section titled “Collecting the files”Here is how the starter project does it. It uses Vite, so it globs with
import.meta.glob:
const gameComponents = importAll( import.meta.glob('./**/*.component.ts', { eager: true }),) as ComponentConstructor[];const gameSystems = importAll( import.meta.glob('./**/*.system.ts', { eager: true }),) as SystemConstructor[];const gameBehaviors = importAll( import.meta.glob('./**/*.behavior.ts', { eager: true }),) as BehaviorConstructor[];importAll is two lines and lives in your project, not in the engine:
export const importAll = (modules: Record<string, unknown>): unknown[] => Object.values(modules).map( (module) => (module as Record<string, unknown>).default, );The pattern has to be a literal at the call site. Vite rewrites import.meta.glob at build
time, so it cannot come from a variable or be wrapped in a helper of your own.
On another bundler the shape of this code changes, and only this code. With webpack the
equivalent is require.context('./', true, /\.component\.ts$/), and importAll takes a
__WebpackModuleApi.RequireContext instead. That also means adding @types/webpack-env to
your dev dependencies, or TypeScript will not know the symbol.
Handing them to the engine
Section titled “Handing them to the engine”The collected arrays are spread in alongside the built-ins:
const engine = new Engine({ config, systems: [Renderer, PhysicsSystem, BehaviorSystem, ...gameSystems], components: [Transform, Sprite, Behaviors, ...gameComponents], assets: [Texture], resources: { [BehaviorSystem.systemName]: [...gameBehaviors], },});Behaviors are not systems or components, so they arrive as a resource for the behavior system rather than in either array. Shaders and filter effects work the same way, keyed to the renderer instead:
resources: { [BehaviorSystem.systemName]: [...gameBehaviors], [Renderer.systemName]: { shaders: [...gameShaders], filterEffects: [...gameFilterEffects], },},From the engine’s point of view nothing unusual happened. It received arrays of classes, exactly as it would have if you had listed them by hand.
Collected is not the same as running
Section titled “Collected is not the same as running”The glob makes a class available to the engine. Whether it does anything after that depends on the kind.
A system runs only if systems in the configuration lists it too. That list is what the
editor’s Systems tab writes, and its order is the order the
systems run in.
A component needs no second list. It is live wherever an actor carries it.
A behavior needs both. BehaviorSystem has to be in systems, and an actor’s
Behaviors component has to name the behavior. The starter project enables that system
already.
When the class never arrives
Section titled “When the class never arrives”The configuration refers to classes by name, so a name with nothing behind it is possible.
There are two reasons for it: the file stopped matching the glob, or the class never reached
new Engine(…) at all. The second one has a common source. The editor writes names, not
classes, so a system you
add in the Systems tab still has to reach the entry point from your code.
Every kind fails the same way. The engine drops the entry, writes one line to the console, and builds the rest:
| Kind | What happens |
|---|---|
| System | Dropped. System not found: … in the console, and the game runs without it |
| Behavior | Dropped. Behavior not found: …, and the actor keeps running |
| Component | Dropped. Component not found: …, and the actor is built without it |
Nothing stops, so nothing points at the problem. A renamed component file leaves actors that look right and hold none of the data that component carried. Watch the console.
What the decorators cost your game
Section titled “What the decorators cost your game”Almost nothing. @DefineComponent sets the static name on the class. It also stores the
fields and widget options on the class, once, when the class is defined. The engine never
reads them. Only the editor does.
The round trip
Section titled “The round trip”The payoff is the loop between the editor and your project.
Generate a component from the editor. It writes health.component.ts into your project.
Two independent things then happen without you doing anything:
- Your game picks it up on the next rebuild, because the glob matches the filename.
- The editor picks it up by importing the file and finding the decorated class among its exports. The decorator describes the class and its fields to the inspector.
The component becomes placeable on actors and its fields become editable, in the same session, from a class that did not exist a minute ago.
See generating scripts for the editor half, and components for what goes in the file.