logo

Babylon.js Market

← Members Only

Anatomy of a Component

Download, inject, and sell assets & code on BabylonJS Market

bjs inject reads src/components/<Name>/ and copies what it finds into the pro package. The folder shape is the same one arcade eject produces and the same one @babylonjsmarket/arcade ships with — it's the lingua franca of this ecosystem.

Look at packages/arcade/src/Components/Health/ for a real reference: a Component, a System, a pure core, two test files, and a meta.json. Every file is small.

The full layout

text
src/components/Bouncer/
ā”œā”€ā”€ Bouncer.ts                 Component + System pair (required)
ā”œā”€ā”€ Bouncer.core.ts            Pure logic the System delegates to (optional)
ā”œā”€ā”€ Bouncer.test.ts            Unit tests (optional, carried along)
ā”œā”€ā”€ Bouncer.contract.test.ts   Framework contract test (optional)
ā”œā”€ā”€ Bouncer.viz.tsx            Solid debug panel (optional)
ā”œā”€ā”€ Bouncer.panel.tsx          Solid control panel (optional)
└── meta.json                  Marketplace metadata (recommended)

bjs inject walks the folder and copies every .ts/.tsx file inside. meta.json is read for its dependencies array (used by the closure walk) and carried with the rest. Anything else in the folder — README, assets, fixtures — is copied too when the target is arcade, because the whole folder is cp -r'd.

<Name>.ts — Component + System

The mandatory file. Defines the Component class (data), the System class (logic), and the Events constants that announce what the System emits and listens to. Convention:

TypeScript
import { Component, System, type ISystemQuery } from '@babylonjsmarket/ecs';

export class BouncerComponent extends Component {
  bounciness = 0.8;
  cooldownMs = 100;
  lastBounceAt = 0;
}

export const BouncerEvents = {
  BOUNCED: 'bouncer.bounced',
} as const;

export const BouncerInputEvents = {
  DISABLE: 'bouncer.disable',
} as const;

export class BouncerSystem extends System {
  query: ISystemQuery = { required: [BouncerComponent] };

  onUpdate(dt: number) {
    for (const entity of this.entities) {
      // ...
    }
  }
}

The class names follow the {Name}Component / {Name}System / {Name}Events / {Name}InputEvents convention. This is what lets the lazy SceneLoader find your classes from a JSON scene file. The pattern is non-negotiable for arcade components — break it and the scene loader won't find them.

<Name>.core.ts — pure logic (optional but encouraged)

Splitting the System into a thin shell and a pure core makes the logic unit-testable without any renderer. Convention:

TypeScript
// Bouncer.core.ts
export function reflectVelocity(
  vx: number,
  vy: number,
  vz: number,
  normalX: number,
  normalY: number,
  normalZ: number,
  bounciness: number,
): { vx: number; vy: number; vz: number } {
  const dot = vx * normalX + vy * normalY + vz * normalZ;
  return {
    vx: (vx - 2 * dot * normalX) * bounciness,
    vy: (vy - 2 * dot * normalY) * bounciness,
    vz: (vz - 2 * dot * normalZ) * bounciness,
  };
}

The System imports reflectVelocity and calls it inside onUpdate. Tests against Bouncer.core.ts are pure-function tests — no World, no renderer, no setup.

Optional. Small Systems don't need it. Large Systems are easier to maintain with it.

<Name>.test.ts — unit tests

Vitest unit tests. They run inside the pro package's CI once injected, so they have to be self-contained:

TypeScript
import { describe, it, expect } from 'vitest';
import { World, MockRendererAdapter } from '@babylonjsmarket/ecs';
import { BouncerComponent, BouncerSystem } from './Bouncer';

describe('BouncerSystem', () => {
  it('emits bouncer.bounced when a tracked entity hits a surface', () => {
    const world = new World({ renderer: new MockRendererAdapter() });
    // ...
  });
});

Optional. If you have them, they come along.

<Name>.contract.test.ts — framework contract

Uses the ecs package's runMechanicContract helper to prove your component honors the framework contract: lifecycle hooks, event names, serialization round-trip.

TypeScript
import { runMechanicContract } from '@babylonjsmarket/ecs/testing';
import { BouncerComponent, BouncerSystem, BouncerEvents } from './Bouncer';

runMechanicContract({
  name: 'Bouncer',
  Component: BouncerComponent,
  System: BouncerSystem,
  events: BouncerEvents,
});

Optional. Strongly recommended for anything shipping in the pro packages — it's the single biggest defense against silent regressions when the framework evolves.

<Name>.viz.tsx — debug viz panel (optional)

A Solid panel that visualizes live state. Renders a small UI showing the Component's fields and any per-frame data the System exposes.

TypeScript
import { definePanel } from '@babylonjsmarket/viz';
import { BouncerComponent } from './Bouncer';

export const BouncerViz = definePanel({
  name: 'Bouncer',
  component: BouncerComponent,
  render: (data) => (
    <div>
      <div>bounciness: {data.bounciness.toFixed(2)}</div>
      <div>last bounce: {data.lastBounceAt}</div>
    </div>
  ),
});

Optional. When present, bjs inject notices the Solid import. If the seed otherwise looks like arcade material, the Solid .tsx alone won't tip it to viz — but if you're authoring a panel-only seed (a debugger, a stepper), this file is what gets the seed routed to viz's solid layer.

<Name>.panel.tsx — control panel (optional)

A Solid panel that lets you change Component fields at runtime. Sliders, toggles, the kind of thing you use to tune feel during development.

TypeScript
import { definePanel } from '@babylonjsmarket/viz';
import { BouncerComponent } from './Bouncer';

export const BouncerPanel = definePanel({
  name: 'Bouncer.controls',
  component: BouncerComponent,
  render: (data, mutate) => (
    <div>
      <label>bounciness
        <input
          type="range" min="0" max="1" step="0.05"
          value={data.bounciness}
          onInput={(e) => mutate({ bounciness: parseFloat(e.currentTarget.value) })}
        />
      </label>
    </div>
  ),
});

Optional. Same routing rules as the viz panel.

meta.json — marketplace metadata

JSON
{
  "name": "Bouncer",
  "description": "Reflects an entity's velocity off a hit normal with configurable bounciness and a cooldown to avoid jitter on shallow grazing contacts.",
  "category": "physics",
  "components": ["BouncerComponent"],
  "systems": ["BouncerSystem"],
  "dependencies": ["Physics"]
}

Read by:

  • bjs inject — dependencies feeds the closure walk so a listed sibling is co-injected.
  • The marketplace product page (future, see publish as a product) — renders title, description, category badge, dependency graph.

A malformed meta.json is tolerated — bjs inject falls back to the import scan to find dependencies. A missing meta.json is fine for inject; the marketplace publish step will ask for one later.

Why each file is optional

The required surface is just <Name>.ts. Everything else is either a quality safeguard (tests, contract) or a presentation surface (viz, panel, meta). You can ship a Bouncer with one file. You shouldn't — but you can.

The pro packages' CI doesn't reject components that lack tests. The marketplace listing pages render fine without a viz panel. Adding each optional file makes your component better; missing any one of them doesn't block the publish.

What's next

The next section, target selection, explains how bjs inject decides between arcade and viz and how to override the choice.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search