# `@combos-fun/engine` — Plugin authoring spec

This file is the standard for authoring any new `@combos-fun/plugin-*` package — npm-published or project-local. Read it only when you are **creating** a plugin. Day-to-day engine usage is covered in `@combos-fun/engine/agent-skill`.

A plugin is `Component(s)` + optional `System(s)` + optional `Renderer` / `Renderer3D` subclass, shipped under the `@combos-fun/` namespace as an npm package or as a folder inside a user project.

## When to read

- You are adding a new feature that does not match any existing `@combos-fun/plugin-*` keyword.
- You are publishing a new plugin to npm under `@combos-fun/*`.
- You are forking / extending an existing plugin's `Renderer` / `Renderer3D` subclass.

For pure consumption of existing plugins, this file is not required — read `@combos-fun/engine/agent-skill` plus each plugin's `agent-skill` instead.

## Required package layout

```
packages/<plugin-name>/
  package.json
  combos-plugin.json    # machine-readable manifest (validated against schemas/combos-plugin.schema.json)
  agent-skill.md        # per-package agent notes
  README.md             # human-facing docs
  index.js              # CJS entry
  lib/                  # TypeScript sources
  dist/                 # built CJS / ESM / .d.ts (created by build-package.mjs)
```

Project-local plugins (not published to npm) may skip `dist/`, the build step, and `combos-plugin.json` — but adopting both unlocks the same selection logic the entry skill uses for official plugins.

## Required `package.json` fields

```json
{
  "name": "@combos-fun/plugin-xxx",
  "files": ["dist", "index.js", "agent-skill.md", "combos-plugin.json"],
  "exports": {
    ".": "./dist/plugin-xxx.esm.js",
    "./plugin-manifest": "./combos-plugin.json",
    "./agent-skill": "./agent-skill.md"
  },
  "combos": {
    "pluginManifest": "./combos-plugin.json"
  }
}
```

The `./plugin-manifest` and `./agent-skill` subpaths are part of the public contract. External Agents resolve documentation via these stable subpaths. Renaming the underlying files without updating `exports` is a breaking change.

## Component subclass

Subclass `Component` from `@combos-fun/engine`. Set `static componentName = 'MyFeature'`.

Lifecycle (all optional):

| Hook | When |
|------|------|
| `init(params?)` | During construction |
| `awake()` | When added to a GameObject |
| `start()` | Inline on first `update` tick (same frame as first `update`) |
| `update(frame)` | Every frame (`frame.deltaTime` in ms) |
| `lateUpdate(frame)` | After all components' `update`, before any `System.update` |
| `onPause()` / `onResume()` | Game pause / resume |
| `onDestroy()` | Component or GameObject destroyed |

`UpdateParams`: `deltaTime`, `frameCount`, `time`, `currentTime`, `fps`.

## System subclass

Subclass `System`. Set `static systemName = 'MyFeatureSystem'`.

When the package is built with `scripts/build-package.mjs`, each `System` class in `lib/` automatically receives `static packageName` and `static packageVersion` from `package.json` (via generated `lib/__combosPackageMeta.gen.ts`). These values are included in the iframe `combos-game:plugin-init-success` postMessage — do not set them manually in source.

Decorate with `@decorators.componentObserver({ MyFeature: ['power'] })`:

- `[]` (empty array) → ADD / REMOVE only
- `['prop']` → CHANGE on set
- `{ prop: ['a','b'], deep: true }` → deep change watching

In `update()`:

```ts
const changes = this.componentObserver.clear();
for (const c of changes) {
  switch (c.type) {
    case OBSERVER_TYPE.ADD:    /* c.component, c.gameObject */ break;
    case OBSERVER_TYPE.CHANGE: /* c.prop?: { deep, prop: string[] } */ break;
    case OBSERVER_TYPE.REMOVE: break;
  }
}
```

System lifecycle is the same as Component (`init` can be async). `init` receives constructor params; `this.game` is available.

`System.destroy()` nulls internals and calls `onDestroy()` but **does not** remove the system from Game. Always call `game.removeSystem(system)`, which calls `destroy()` internally.

### `ComponentChanged` shape

`type: OBSERVER_TYPE`, `component`, `componentName`, `gameObject`, `prop?: { deep, prop: string[] }`.

## 2D Pixi `Renderer` subclass (`@combos-fun/plugin-renderer`)

Extend `Renderer` from `plugin-renderer` with `@decorators.componentObserver`.

```ts
class MyRenderer extends Renderer {
  init() {
    this.rendererSystem = this.game.getSystem(RendererSystem);
    this.rendererSystem.rendererManager.register(this);
  }
  componentChanged(changed: ComponentChanged) {
    const container = this.rendererSystem.containerManager.getContainer(
      changed.gameObject.id,
    );
    switch (changed.type) {
      case OBSERVER_TYPE.ADD: /* create pixi object, container.addChild(obj) */ break;
      case OBSERVER_TYPE.CHANGE: /* mutate */ break;
      case OBSERVER_TYPE.REMOVE: /* destroy + container.removeChild */ break;
    }
  }
  rendererUpdate(gameObject) {
    /* per-frame sync */
  }
}
```

`RendererSystem` must be added before any system calling `getSystem(RendererSystem)` in `init`.

## 3D Three.js `Renderer3D` subclass (`@combos-fun/plugin-renderer-3d`)

Extend `Renderer3D` with `@decorators.componentObserver`.

```ts
class MyRenderer3D extends Renderer3D {
  init() {
    this.rendererSystem = this.game.getSystem(Renderer3DSystem);
    this.rendererSystem.rendererManager.register(this);
  }
  componentChanged(changed) {
    const scene = this.threeContext.scene;
    /* create / mutate / dispose THREE.Object3D */
  }
  rendererUpdate(gameObject) { /* per-frame */ }
}
```

`Renderer3DSystem` must be added before any system calling `getSystem(Renderer3DSystem)` in `init`.

**Async loading pattern** (3D only): use the inherited `increaseAsyncId(id)` before async work and `validateAsyncId(id, asyncId)` after each `await` to cancel stale operations when the component is removed mid-load.

## `combos-plugin.json` (manifest)

Validate against `schemas/combos-plugin.schema.json`. Required fields: `name`, `pluginId`, `category`, `dimension`, `agentSkill`. For non-core packages, `category` ∈ {`rendering`, `physics`, `audio`, `input`, `ui`, `a11y`, `animation`, `devtool`, `other`} and `dimension` ∈ {`2d`, `3d`, `shared`}.

## `agent-skill.md` template (per-plugin notes)

```md
# <plugin-name> — Agent notes

## When to read
<!-- which tasks should load this -->

## Public API
<!-- exported components / systems / params -->

## Required setup
<!-- system registration order, dependencies -->

## Runtime behaviour
<!-- lifecycle interactions, frame-order specifics -->

## Common pitfalls
<!-- blank canvas, missing systems, async stale, etc -->

## Minimal example
<!-- shortest copy-pasteable snippet -->

## Verification
<!-- how to run / test the plugin after changes -->
```

## Build & publish requirements (npm-published plugins only)

- Build: CJS / ESM + `.d.ts` to `dist/` via `node ../../scripts/build-package.mjs`.
- Peer / runtime deps: `@combos-fun/engine`, optionally `plugin-renderer` + `pixi.js` (for 2D renderer plugins) or `plugin-renderer-3d` + `three` (for 3D renderer plugins).
- `package.json`: include `agent-skill.md` and `combos-plugin.json` in `files`; expose `./plugin-manifest` and `./agent-skill` in `exports`.
- Run `pnpm validate-plugin-manifests --strict` before publishing. The publish script does this automatically.
- Duplicate registration of the same System class is warned and skipped.

Project-local plugins skip all of this: no build, no manifest, no schema validation, no exports, no publish. The Component / System / Renderer code itself is identical.
