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.
Install
Section titled “Install”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 WebViewnpm install aibridgejs- Cocos Creator resolves bare specifiers with the Node.js algorithm and honours
package.jsonexportsunder theimportanddefaultconditions (plusbrowserby default;requireandnodeare not supported). The ai*js packages are ES modules, so Cocos loads their ESM builds and subpaths such asaibridgejs/detectimport directly. The manual uses npm; a symlinked pnpmnode_moduleshas not been verified. - Cocos compiles scripts without reading
tsconfig.json, ignores type errors and accepts TypeScript 4.1 syntax, so the examples use separateimport typelines and avoidsatisfies,overrideandimport { type X }. tsconfig.jsononly affects the IDE. If it cannot find the types of a subpath, add"moduleResolution": "bundler"tocompilerOptions(the IDE needs TypeScript 5.0 or later); the manual only asks you not to changetargetandmodule.
Compatibility
Section titled “Compatibility”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,aiquadtreejsand theaiecsjsroot touch no DOM or web-only globals.AbortSignalis only involved when you pass asignaloption, andaieventjsonly callsperformance.now()whenthrottleMsis set. aifsmjsconstructs anAbortControllerin everycreateRuntime()(aibridgejson its firstready(),call()oremit()). Neither the native nor the WeChat mini-game adapter of Cocos 3.8.8 provides one.aispritejsandaifsmjscallObject.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/serializedeep-copies AoS components withstructuredClone, which the native runtime lacks.aiecsjs/workerneedsSharedArrayBuffer, Web Workers and cross-origin isolation, andaibridgejsneedswindow.postMessageor a Flutter WebView.- Off the web, timing is coarser: the native adapter checks
setTimeoutonce per frame, and the WeChat adapter implementsperformance.nowwithDate.now. This affectsaifsmjs/timerdelays andaieventjsthrottleMswindows. aiaudiojswraps Howler.js and Web Audio and adds unlock listeners todocument. Native builds have neither, the mini-game adapter provides noAudioContext, and on the web it would run beside Cocos’s ownAudioContext, outside theAudioClippipeline; useAudioSource. Likewise,aispritejs/pixitargets PixiJS v8,aispritejs/atlasparses PixiJS atlas JSON (Cocos imports only.plistatlases), andaiecsjs/loopduplicatesupdate(dt).
AbortController polyfill
Section titled “AbortController polyfill”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.
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.
Examples
Section titled “Examples”Each example is a component in assets/scripts/ that you attach to a node. Cocos passes dt to update(dt) in seconds.
aispritejs: frames from a SpriteAtlas
Section titled “aispritejs: frames from a SpriteAtlas”aispritejs picks the state and frame key, and the component shows the SpriteFrame of the same name from a SpriteAtlas.
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)andhero.animator.fireTrigger("jump").loopdefaults tofalse, and frames last 100 ms unlessdefaultFrameDurationorframessay otherwise. getSpriteFrame()returnsnullfor an unknown key, which blanks the sprite. Cocos imports only Cocos2d-x.plistatlases (TexturePacker 4.x), so use the keys the imported atlas has; the manual’s example keys have no file extension, such assheep_down_0.- Since 0.6.0,
createSpriteAnimator()throwsInvalidGraphErrorfor a non-string identifier (such asinitial,fromorto) or a non-integerpriority, which matters for graphs loaded from JSON. Anupdate()orreset()called from anonStateChangeoronCompletelistener is queued until the current call finishes.
aifsmjs: enemy states
Section titled “aifsmjs: enemy states”The rules live in the machine. The component reports whether the target is in sight and moves the node while the state is chase.
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 contextconst 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.deadisfinal, so the runtime ignores every later event. - Since 0.6.0,
send()runs to completion: asend()made from a listener, middleware or effect is queued until the current event has finished, and returns the snapshot committed at that moment. ReadgetSnapshot()after the outer call returns, astakeHit()does. - Keep the context to plain data. When
process.env.NODE_ENVis readable and notproduction, snapshots are deep-frozen, which would freeze aNodestored in the context.
aieventjs: typed game events
Section titled “aieventjs: typed game events”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.
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 }). Avoidevent still takesundefined, as inemit("score/reset", undefined). Declare the event map withtype, notinterface. - 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 thatemit().
aipooljs: pooling instantiate(prefab)
Section titled “aipooljs: pooling instantiate(prefab)”The pool holds bullet nodes created from a prefab; a component on the prefab moves each bullet.
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
NodePoolhas no capacity limit: when it is empty youinstantiate()inside the game loop, and a repeatedput()goes unnoticed.createPool()callscreatesizetimes up front,onOverflowdecides what an empty pool does ("throw","null"or"grow"), and releasing an object twice throwsPoolError. - Since 0.6.0,
createPool()also throwsPoolErrorwhensizeis not a non-negative integer,createorresetis not a function, orcreatereturnsnullorundefined. 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.
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 eachboundsfield once and throwsQuadtreeErrorfor missing bounds or a non-positive width or height, so the arena needs a content size. A CocosRecthas 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 readworldPosition. Objects entirely outside the arena’s bounds atstart()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.
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
forEachEntityIndexedcallback receives(e, i, ...columns): index SoA fields aspos.x[i]and an AoS column asview[i], and never useeas the index. Useaiecsjs/commandsto add or remove entities inside the loop. - Since 0.6.0,
createWorld()throwsEcsErrorfor a non-integerinitialCapacity. A plainnumberproperty is a float in the Inspector, henceCCIntegerforcount. - To save worlds with
aiecsjs/serialize, give each component a stable key, such asdefineComponent(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 throwsEcsError.
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.
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; }}targetOriginmust be the host page’s exact origin ("*"is rejected), andexpectedSourceaccepts messages fromwindow.parentonly. Treat every payload from the host as untrusted input.- Since 0.6.0, the
timeoutMsofcall()(5 s here, set increateBridge()) also covers the wait for the host to become ready, so an unresponsive host rejects withBridgeTimeoutError.emit()has no deadline unless you passtimeoutMs, anddispose()rejects anything still in flight withBridgeDisposedError. - Flutter InAppWebView on iOS and macOS exposes no ready flag, so a Cocos bundle that loads after
flutterInAppWebViewPlatformReadyhas fired waits until the call times out; passdetectBridgeAdapter(window, { flutter: { waitForReadyEvent: false }, iframe: { ... } })there. For native builds, use Cocos’sJsbBridgeorJsbBridgeWrapper.
Further reading
Section titled “Further reading”- Package guides: aispritejs, aifsmjs, aieventjs, aipooljs, aiquadtreejs, aiecsjs, aibridgejs
- Cocos Creator 3.8 manual: Module Specification, How to Get npm Packages, Programming Language Support