Skip to content

Cocos Creator Integration Guide

The aijs cores are renderer-agnostic TypeScript, so they can own game logic, data and messaging in a Cocos Creator project while rendering, audio and asset loading stay with Cocos. aijs decides what should happen; a component applies the result to nodes.

This page targets Cocos Creator 3.8.8 (December 2025, the latest stable release of the 3.8 LTS line) and ai*js 0.6.0. COCOS 4 is still at 4.0.0-alpha and is not covered.

Run this in the project root, the directory that holds assets/ and package.json:

終端機視窗
npm install aispritejs aifsmjs aieventjs aipooljs aiquadtreejs aiecsjs
# only for web builds embedded in an iframe or a Flutter WebView
npm install aibridgejs
  • Cocos Creator resolves bare specifiers with the Node.js algorithm and honours package.json exports under the import and default conditions (plus browser by default; require and node are not supported). The ai*js packages are ES modules, so Cocos loads their ESM builds and subpaths such as aibridgejs/detect import directly. The manual uses npm; a symlinked pnpm node_modules has not been verified.
  • Cocos compiles scripts without reading tsconfig.json, ignores type errors and accepts TypeScript 4.1 syntax, so the examples use separate import type lines and avoid satisfies, override and import { type X }.
  • tsconfig.json only affects the IDE. If it cannot find the types of a subpath, add "moduleResolution": "bundler" to compilerOptions (the IDE needs TypeScript 5.0 or later); the manual only asks you not to change target and module.

Web means Web Desktop and Web Mobile, including builds embedded in an iframe or a Flutter WebView. Native means Android, iOS, macOS and Windows, where Cocos 3.8.8 runs scripts on V8 11.6 (--jitless on iOS). The mini-game column uses WeChat as the reference (JavaScriptCore on iOS, V8 on Android, no DOM); other mini-game platforms have not been checked.

Package Web Native Mini-game
aispritejs (core) Yes Yes Yes; mind Object.hasOwn
aifsmjs Yes With the AbortController polyfill With the polyfill; mind Object.hasOwn
aieventjs, aipooljs, aiquadtreejs Yes Yes Yes
aiecsjs (root) Yes Yes Yes
aiecsjs/serialize Yes Not with AoS components Unverified
aiecsjs/worker Cross-origin isolated pages only No No
aibridgejs Yes No No
aiaudiojs Not recommended No No
  • The core paths of aispritejs, aieventjs, aipooljs, aiquadtreejs and the aiecsjs root touch no DOM or web-only globals. AbortSignal is only involved when you pass a signal option, and aieventjs only calls performance.now() when throttleMs is set.
  • aifsmjs constructs an AbortController in every createRuntime() (aibridgejs on its first ready(), call() or emit()). Neither the native nor the WeChat mini-game adapter of Cocos 3.8.8 provides one.
  • aispritejs and aifsmjs call Object.hasOwn (ES2022) on their core paths. JavaScriptCore before iOS 15.4 lacks it, which affects web builds on older iOS and WeChat mini-games on iOS; polyfill it at start-up if you support those devices.
  • aiecsjs/serialize deep-copies AoS components with structuredClone, which the native runtime lacks. aiecsjs/worker needs SharedArrayBuffer, Web Workers and cross-origin isolation, and aibridgejs needs window.postMessage or a Flutter WebView.
  • Off the web, timing is coarser: the native adapter checks setTimeout once per frame, and the WeChat adapter implements performance.now with Date.now. This affects aifsmjs/timer delays and aieventjs throttleMs windows.
  • aiaudiojs wraps Howler.js and Web Audio and adds unlock listeners to document. Native builds have neither, the mini-game adapter provides no AudioContext, and on the web it would run beside Cocos’s own AudioContext, outside the AudioClip pipeline; use AudioSource. Likewise, aispritejs/pixi targets PixiJS v8, aispritejs/atlas parses PixiJS atlas JSON (Cocos imports only .plist atlases), and aiecsjs/loop duplicates update(dt).

Native and mini-game builds that use aifsmjs need the minimal implementation below. It covers only what ai*js uses (abort(), signal.aborted, signal.reason, and addEventListener / removeEventListener for abort) and leaves a built-in AbortController untouched. Use a complete polyfill if other code needs AbortSignal.timeout(), AbortSignal.any() or dispatchEvent.

assets/scripts/abort-controller-lite.ts
class AbortSignalLite {
aborted = false;
reason: unknown = undefined;
private readonly listeners = new Set<() => void>();
addEventListener(type: string, listener: () => void): void {
if (type === "abort" && !this.aborted) this.listeners.add(listener);
}
removeEventListener(type: string, listener: () => void): void {
if (type === "abort") this.listeners.delete(listener);
}
fire(reason: unknown): void {
if (this.aborted) return;
this.aborted = true;
this.reason = reason;
this.listeners.forEach((listener) => {
try {
listener.call(this);
} catch (err) {
console.error(err);
}
});
this.listeners.clear();
}
}
class AbortControllerLite {
readonly signal = new AbortSignalLite();
abort(reason?: unknown): void {
const error = new Error("This operation was aborted");
error.name = "AbortError";
this.signal.fire(reason === undefined ? error : reason);
}
}
const g = globalThis as unknown as { AbortController?: unknown };
if (typeof g.AbortController === "undefined") g.AbortController = AbortControllerLite;
export {};

Import it with import "./abort-controller-lite"; in every script that calls createRuntime(). To avoid the polyfill, drive the machine with the pure step(def, snapshot, event, impl) and initialSnapshot(def) functions and keep the snapshot yourself.

Each example is a component in assets/scripts/ that you attach to a node. Cocos passes dt to update(dt) in seconds.

aispritejs picks the state and frame key, and the component shows the SpriteFrame of the same name from a SpriteAtlas.

assets/scripts/HeroSprite.ts
import { _decorator, Component, Sprite, SpriteAtlas } from "cc";
import { createSpriteAnimator } from "aispritejs";
import type { SpriteGraph } from "aispritejs";
const { ccclass, property, requireComponent } = _decorator;
const HERO_GRAPH: SpriteGraph = {
inputs: { speed: { type: "number" }, jump: { type: "trigger" } },
initial: "idle",
states: {
idle: { animation: "idle", loop: true },
run: { animation: "run", loop: true },
jump: { animation: "jump", onEnd: "idle" },
},
transitions: [
{ from: "idle", to: "run", when: [{ input: "speed", op: "GreaterThan", value: 0 }] },
{ from: "run", to: "idle", when: [{ input: "speed", op: "Equals", value: 0 }] },
{ from: "*", to: "jump", when: [{ input: "jump", op: "Trigger" }] },
],
animations: {
idle: ["hero_idle_0", "hero_idle_1"],
run: ["hero_run_0", "hero_run_1", "hero_run_2", "hero_run_3"],
jump: ["hero_jump_0", "hero_jump_1", "hero_jump_2"],
},
};
@ccclass("HeroSprite")
@requireComponent(Sprite)
export class HeroSprite extends Component {
@property(SpriteAtlas)
atlas: SpriteAtlas | null = null;
readonly animator = createSpriteAnimator(HERO_GRAPH);
private shownKey = "";
update(dt: number) {
this.animator.update(dt * 1000); // aispritejs counts milliseconds
const key = this.animator.activeFrameKey;
if (key === this.shownKey || !this.atlas) return;
this.shownKey = key;
this.getComponent(Sprite)!.spriteFrame = this.atlas.getSpriteFrame(key);
}
onDestroy() {
this.animator.dispose();
}
}
  • Other components drive it with hero.animator.setInput("speed", 1) and hero.animator.fireTrigger("jump"). loop defaults to false, and frames last 100 ms unless defaultFrameDuration or frames say otherwise.
  • getSpriteFrame() returns null for an unknown key, which blanks the sprite. Cocos imports only Cocos2d-x .plist atlases (TexturePacker 4.x), so use the keys the imported atlas has; the manual’s example keys have no file extension, such as sheep_down_0.
  • Since 0.6.0, createSpriteAnimator() throws InvalidGraphError for a non-string identifier (such as initial, from or to) or a non-integer priority, which matters for graphs loaded from JSON. An update() or reset() called from an onStateChange or onComplete listener is queued until the current call finishes.

The rules live in the machine. The component reports whether the target is in sight and moves the node while the state is chase.

assets/scripts/EnemyBrain.ts
import "./abort-controller-lite";
import { _decorator, Component, Node, Vec3 } from "cc";
import { assign, createRuntime, setup } from "aifsmjs";
const { ccclass, property } = _decorator;
type EnemyEvent = { type: "SEE" } | { type: "LOSE" } | { type: "HIT"; damage: number };
// the first passing guard wins; the entry without a target only updates the context
const onHit = [
{ target: "dead", guard: "lethal", actions: ["hurt"] },
{ actions: ["hurt"] },
] as const;
const enemyMachine = setup<{ hp: number }, EnemyEvent>().defineMachine({
id: "enemy",
initial: "patrol",
context: { hp: 3 },
states: {
patrol: { on: { SEE: "chase", HIT: onHit } },
chase: { on: { LOSE: "patrol", HIT: onHit } },
dead: { final: true },
},
});
@ccclass("EnemyBrain")
export class EnemyBrain extends Component {
@property(Node)
target: Node | null = null;
private readonly runtime = createRuntime(enemyMachine, {
guards: { lethal: ({ context, event }) => event.type === "HIT" && context.hp <= event.damage },
actions: {
hurt: assign(({ context, event }) => ({
hp: event.type === "HIT" ? context.hp - event.damage : context.hp,
})),
},
});
private readonly next = new Vec3();
takeHit(damage: number) {
this.runtime.send({ type: "HIT", damage });
if (this.runtime.getSnapshot().value === "dead") this.node.active = false;
}
update(dt: number) {
if (!this.target) return;
const from = this.node.worldPosition;
const to = this.target.worldPosition;
// an event the current state does not handle is ignored
this.runtime.send({ type: Vec3.distance(from, to) <= 400 ? "SEE" : "LOSE" });
if (this.runtime.getSnapshot().value !== "chase") return;
this.node.setWorldPosition(Vec3.moveTowards(this.next, from, to, 120 * dt));
}
onDestroy() {
this.runtime.dispose();
}
}
  • The first line imports the polyfill, which lets createRuntime() run in native and mini-game builds. dead is final, so the runtime ignores every later event.
  • Since 0.6.0, send() runs to completion: a send() made from a listener, middleware or effect is queued until the current event has finished, and returns the snapshot committed at that moment. Read getSnapshot() after the outer call returns, as takeHit() does.
  • Keep the context to plain data. When process.env.NODE_ENV is readable and not production, snapshots are deep-frozen, which would freeze a Node stored in the context.

Keep touch and UI events on Cocos’s node.on(), and send game events between components through aieventjs: payloads are typed and each on() returns its own unsubscribe function.

assets/scripts/ScoreHud.ts
import { _decorator, Component, Label } from "cc";
import { createEmitter } from "aieventjs";
const { ccclass, property } = _decorator;
export type HudEvents = { "score/add": { amount: number }; "score/reset": void };
@ccclass("ScoreHud")
export class ScoreHud extends Component {
@property(Label)
label: Label | null = null;
readonly events = createEmitter<HudEvents>();
private score = 0;
private offs: Array<() => void> = [];
onEnable() {
this.offs = [
this.events.on("score/add", ({ amount }) => this.show(this.score + amount)),
this.events.on("score/reset", () => this.show(0)),
];
}
onDisable() {
for (const off of this.offs) off();
}
onDestroy() {
this.events.dispose();
}
private show(score: number) {
this.score = score;
if (this.label) this.label.string = String(score);
}
}
  • Other components call hud.events.emit("score/add", { amount: 10 }). A void event still takes undefined, as in emit("score/reset", undefined). Declare the event map with type, not interface.
  • Since 0.6.0, a handler removed during a dispatch, for example by a component whose onDisable() runs inside another handler, is skipped for the rest of that emit().

The pool holds bullet nodes created from a prefab; a component on the prefab moves each bullet.

assets/scripts/BulletSpawner.ts
import { _decorator, Component, instantiate, Node, Prefab } from "cc";
import { createPool } from "aipooljs";
import type { NullPool } from "aipooljs";
const { ccclass, property } = _decorator;
@ccclass("BulletSpawner")
export class BulletSpawner extends Component {
@property(Prefab)
bulletPrefab: Prefab | null = null;
private pool: NullPool<Node> | null = null;
onLoad() {
const prefab = this.bulletPrefab;
if (!prefab) throw new Error("BulletSpawner: assign a bullet Prefab");
this.pool = createPool<Node>({
size: 64,
onOverflow: "null",
create: () => {
const node = instantiate(prefab);
node.active = false;
this.node.addChild(node);
return node;
},
reset: (node) => {
node.active = false;
},
});
}
start() {
this.schedule(this.fire, 0.1);
}
fire() {
const bullet = this.pool?.acquire();
if (!bullet) return;
bullet.setPosition(0, 0, 0);
bullet.active = true; // a component on the prefab moves it
this.scheduleOnce(() => this.pool?.release(bullet), 1.2);
}
onDestroy() {
this.pool?.dispose();
this.pool = null;
}
}
  • Cocos’s NodePool has no capacity limit: when it is empty you instantiate() inside the game loop, and a repeated put() goes unnoticed. createPool() calls create size times up front, onOverflow decides what an empty pool does ("throw", "null" or "grow"), and releasing an object twice throws PoolError.
  • Since 0.6.0, createPool() also throws PoolError when size is not a non-negative integer, create or reset is not a function, or create returns null or undefined.
  • dispose() only drops the pool’s references. The bullets are children of the spawner node, so they are destroyed with it.

aiquadtreejs: broadphase over UITransform bounds

Section titled “aiquadtreejs: broadphase over UITransform bounds”

UITransform.getBoundingBoxToWorld() returns a new Rect with x, y, width and height, so it already is an AABB. Attach the component to an arena node; every frame it rebuilds the tree from the arena’s children and tints overlapping sprites red.

assets/scripts/BroadphaseArena.ts
import { _decorator, Color, Component, Rect, Sprite, UITransform } from "cc";
import { createQuadtree } from "aiquadtreejs";
import type { AABB, Quadtree } from "aiquadtreejs";
const { ccclass, requireComponent } = _decorator;
type Box = Rect & { sprite: Sprite | null };
const overlaps = (a: AABB, b: AABB) =>
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
@ccclass("BroadphaseArena")
@requireComponent(UITransform)
export class BroadphaseArena extends Component {
private tree: Quadtree<Box> | null = null;
private readonly boxes: Box[] = [];
private readonly candidates: Box[] = [];
start() {
const bounds = this.getComponent(UITransform)!.getBoundingBoxToWorld();
this.tree = createQuadtree<Box>({ bounds, maxObjects: 8, maxLevels: 5 });
}
update() {
const tree = this.tree;
if (!tree) return;
tree.clear();
this.boxes.length = 0;
for (const child of this.node.children) {
const ui = child.getComponent(UITransform);
if (!ui) continue;
const box = Object.assign(ui.getBoundingBoxToWorld(), { sprite: child.getComponent(Sprite) });
this.boxes.push(box);
tree.insert(box);
}
for (const a of this.boxes) {
let hit = false;
for (const b of tree.retrieveInto(a, this.candidates)) {
if (b !== a && overlaps(a, b)) hit = true;
}
if (a.sprite) a.sprite.color = hit ? Color.RED : Color.WHITE;
}
}
onDestroy() {
this.tree?.dispose();
this.tree = null;
}
}
  • createQuadtree() reads each bounds field once and throws QuadtreeError for missing bounds or a non-positive width or height, so the arena needs a content size. A Cocos Rect has its origin at the bottom left with y pointing up; the tree only needs one consistent coordinate system.
  • getBoundingBoxToWorld() allocates on every call and includes active children. With many objects, track sizes yourself and read worldPosition. Objects entirely outside the arena’s bounds at start() are never returned.

aiecsjs: simulation data synchronised to nodes

Section titled “aiecsjs: simulation data synchronised to nodes”

SoA components hold position and velocity, and an AoS component (defineObjectComponent) holds the Node. One pass updates the data and a second writes it to the nodes. The namespace import keeps ecs.addComponent apart from Cocos’s node.addComponent.

assets/scripts/SwarmSimulation.ts
import { _decorator, CCInteger, Component, instantiate, Node, Prefab, randomRange } from "cc";
import * as ecs from "aiecsjs";
const { ccclass, property } = _decorator;
const Position = ecs.defineComponent({ x: ecs.Types.f32, y: ecs.Types.f32 });
const Velocity = ecs.defineComponent({ x: ecs.Types.f32, y: ecs.Types.f32 });
const View = ecs.defineObjectComponent<{ node: Node | null }>(() => ({ node: null }));
const movers = ecs.defineQuery([Position, Velocity]);
const views = ecs.defineQuery([Position, View]);
@ccclass("SwarmSimulation")
export class SwarmSimulation extends Component {
@property(Prefab)
agentPrefab: Prefab | null = null;
@property({ type: CCInteger })
count = 200;
private world: ecs.World | null = null;
onLoad() {
const prefab = this.agentPrefab;
if (!prefab) throw new Error("SwarmSimulation: assign an agent Prefab");
const world = ecs.createWorld({ initialCapacity: this.count });
for (let n = 0; n < this.count; n++) {
const e = ecs.createEntity(world);
ecs.addComponent(world, e, Position, { x: randomRange(-400, 400), y: randomRange(-300, 300) });
ecs.addComponent(world, e, Velocity, { x: randomRange(-80, 80), y: randomRange(-80, 80) });
const node = instantiate(prefab);
this.node.addChild(node);
ecs.addComponent(world, e, View, { node });
}
this.world = world;
}
update(dt: number) {
const world = this.world;
if (!world) return;
ecs.forEachEntityIndexed(world, movers, (_e, i, pos, vel) => {
pos.x[i] += vel.x[i] * dt;
pos.y[i] += vel.y[i] * dt;
if (Math.abs(pos.x[i]) > 400) vel.x[i] = -vel.x[i];
if (Math.abs(pos.y[i]) > 300) vel.y[i] = -vel.y[i];
});
ecs.forEachEntityIndexed(world, views, (_e, i, pos, view) => {
view[i].node?.setPosition(pos.x[i], pos.y[i], 0);
});
}
onDestroy() {
if (this.world) ecs.disposeWorld(this.world);
this.world = null;
}
}
  • The forEachEntityIndexed callback receives (e, i, ...columns): index SoA fields as pos.x[i] and an AoS column as view[i], and never use e as the index. Use aiecsjs/commands to add or remove entities inside the loop.
  • Since 0.6.0, createWorld() throws EcsError for a non-integer initialCapacity. A plain number property is a float in the Inspector, hence CCInteger for count.
  • To save worlds with aiecsjs/serialize, give each component a stable key, such as defineComponent(schema, { key: "swarm.position" }); snapshot format 2 matches components by key. Running a definition again with the same key and field layout, for example after a script reload, returns the existing component, and a different layout throws EcsError.

aibridgejs: web builds hosted in an iframe or a Flutter WebView

Section titled “aibridgejs: web builds hosted in an iframe or a Flutter WebView”

For web builds only. sys.isBrowser skips native and mini-game builds, and without a host detectBridgeAdapter() returns the mock adapter, which the example disposes of.

assets/scripts/HostBridge.ts
import { _decorator, Component, director, sys } from "cc";
import { createBridge } from "aibridgejs";
import type { Bridge } from "aibridgejs";
import { detectBridgeAdapter } from "aibridgejs/detect";
const { ccclass, property } = _decorator;
@ccclass("HostBridge")
export class HostBridge extends Component {
@property
hostOrigin = "https://host.example";
private bridge: Bridge | null = null;
onLoad() {
if (!sys.isBrowser) return;
const bridge = createBridge({
adapter: detectBridgeAdapter(window, {
iframe: { targetOrigin: this.hostOrigin, expectedSource: window.parent },
}),
timeoutMs: 5000,
});
if (bridge.platform() === "mock") {
bridge.dispose();
return;
}
bridge.on<{ paused?: unknown }>("game/pause", (payload) => {
if (payload?.paused === true) director.pause();
if (payload?.paused === false) director.resume();
});
this.bridge = bridge;
bridge
.call<{ name?: unknown }>("user/current")
.then((user) => console.log("[HostBridge] player", user?.name))
.catch((err: unknown) => console.warn("[HostBridge] host did not answer", err));
}
onDestroy() {
this.bridge?.dispose();
this.bridge = null;
}
}
  • targetOrigin must be the host page’s exact origin ("*" is rejected), and expectedSource accepts messages from window.parent only. Treat every payload from the host as untrusted input.
  • Since 0.6.0, the timeoutMs of call() (5 s here, set in createBridge()) also covers the wait for the host to become ready, so an unresponsive host rejects with BridgeTimeoutError. emit() has no deadline unless you pass timeoutMs, and dispose() rejects anything still in flight with BridgeDisposedError.
  • Flutter InAppWebView on iOS and macOS exposes no ready flag, so a Cocos bundle that loads after flutterInAppWebViewPlatformReady has fired waits until the call times out; pass detectBridgeAdapter(window, { flutter: { waitForReadyEvent: false }, iframe: { ... } }) there. For native builds, use Cocos’s JsbBridge or JsbBridgeWrapper.