code-conventions-cocos
Code Conventions — Cocos Creator / TypeScript
Section titled “Code Conventions — Cocos Creator / TypeScript”Extends core code-conventions.md. The1Studio-specific patterns.
Naming
Section titled “Naming”- Classes: PascalCase with semantic suffix —
Service,Component,Controller - Private fields:
_camelCase(underscore prefix — standard TS convention) - Enums: PascalCase names, lowercase string values (
BROWN = 'brown') for serialization - Interfaces: PascalCase, NO
Iprefix (modern TS style, not Java/C#) - Callbacks:
on*orhandle*prefix —onWeaponPlaced,handleTimerEnd - Constants:
UPPER_SNAKE_CASEin config objects - Files:
{Feature}{Suffix}.ts—ColorService.ts,TimerComponent.ts
Architecture
Section titled “Architecture”- Service: business logic, singleton — reusable utilities
- Component: UI, extends
Component— scene-attached - Controller: orchestration, state management
- Singleton:
private static _instance+static get instance(), set inonLoad()
SignalBus (Critical)
Section titled “SignalBus (Critical)”- Use
signalBus.subscribe/unsubscribe()for inter-component events — NOT direct calls - MUST use named methods (arrow function class fields), NOT inline lambdas
- Reason:
unsubscribe()usesindexOf()— lambdas create new refs each call - Unsubscribe in
onDestroy()— always clean up
Decorators
Section titled “Decorators”@ccclass('ClassName')on every component class@property(Type)for editor-exposed fields only- Destructure at top:
const { ccclass, property } = _decorator;
Patterns
Section titled “Patterns”- Guard clauses for null safety — early return, not nested conditions
readonlyfor config values — prevents runtime mutation- Extract magic numbers to
GameConfigor localconst - Lifecycle order:
onLoad→onEnable→start→update→onDestroy
Release build ≠ editor preview — never spread a non-array
Section titled “Release build ≠ editor preview — never spread a non-array”The release build transpiles spread in loose mode: [...x] is emitted as [].concat(x). concat spreads an array argument and appends anything else as a single element, so [...map.keys()] yields [MapIterator] in a build and the correct array in editor preview. Nothing warns — the type checker still sees number[].
Array.from(x)for every Map, Set, iterator,matchAll,arguments, NodeList. Spread stays fine on arrays and on object literals ({...cfg}).for...ofover a Map/Set is safe — it transpiles to a real iterator helper.- No unit test can catch this. Vitest runs on Node, where the spread is real; the defect exists only in the transpiled bundle.
Observed 2026-08-19 (MarbleDropPLA): [...this._marbleAtSlot.keys()] in a conveyor ring model wiped the slot table every tick, freezing every marble on the belt in the shipped playable while the editor ran clean. Detail: the t1k-cocos-base-code-conventions skill, § “Why Array.from, never spread”.
File Organization
Section titled “File Organization”The by-type layout below is the playable-ad shape — one short-lived deliverable, ~2-5MB, no test suite. It is correct for playable ads and wrong for anything longer-lived.
assets/scripts/{ProjectName}/├── services/ # Business logic singletons├── parameter/ # Config management├── UI/ # UI components and controllers├── utils/ # Static utility classes├── constant/ # Named constants├── Data/ # Models, enums, configs└── Signal/ # Signal class definitions + shared signalBus exportFeature modules & testability
Section titled “Feature modules & testability”For a game feature module (anything with rules, formulas, state transitions, or validation that outlives a single playable), do NOT use the by-type layout above. Use the vertical feature module from t1k-game-arch (ships in theonekit-core’s t1k-extended, a hard dependency of cocos:base) for the engine-agnostic doctrine, specialized for Cocos by t1k-cocos-base-clean-architecture (composition root, signalBus, ESLint zones, Vitest) — do not re-derive the layer rules here.
Two disciplines are non-negotiable, and both are cheapest to get right at plan time, not during implementation:
- A module ships with its own tests, in a dot-prefixed
.tests/directory. Tests live inside the module ({feature}/.tests/{domain,application}/), not in a sibling module or a global bucket —t1k-game-archreferences/testing-strategy.md§ “Test structure” for the doctrine, but note it names a plaintests/; on Cocos the leading dot is mandatory (§ below). Objective test: deleting the module directory deletes its tests with it and leaves nothing dangling —.tests/satisfies this exactly astests/would, because the dot changes what the asset-db sees, not where the files live. If you cannot scope a test suite to the seam without reaching into another module’s internals, the seam is in the wrong place — move the seam, don’t widen the test. - Game logic lives outside
cc.Component. Rules, formulas, state transitions and validation go in plain TS classes that the@ccclasslayer drives. A@ccclassnode script that owns game rules cannot be tested without a scene load. Enforcement + the ESLintno-restricted-pathsconfig are int1k-cocos-base-clean-architecture.
The payoff is the whole point: pure-TS logic is testable with Vitest under environment: 'node' — no Cocos runtime, no scene load, no editor, in milliseconds. This is the only automated test path this kit has; see § “Testing reality” below.
This is a placement rule, not a layering mandate: coding-guidelines.md §2 (YAGNI) still governs. Put logic where it can be tested — do not add an interface and four directories to a class that has neither rules nor collaborators.
Colocated tests must be dot-prefixed — .tests/, never tests/
Section titled “Colocated tests must be dot-prefixed — .tests/, never tests/”Cocos imports EVERY .ts under assets/ as a script asset and links it into the runtime bundle, so a colocated tests/ or __tests__/ ships: each spec gets a generated .meta and is loaded at boot. One spec importing a Node builtin then kills preview outright — Error: Can not load module node:fs. Cause: Node.js builtin modules are not provided by Cocos Creator. — and every spec importing vitest is the same class of problem, it just surfaces second.
The asset-db skips any entry whose name begins with a dot, so a .tests/ tree gets no .meta, never enters the bundle, and stays colocated. The mechanism is the absent .meta, not the name — do not tidy the dot away in a later phase.
| Tool | With a .tests/ dir |
|---|---|
| Vitest (tinyglobby) | matches only if the include names the dot: **/.tests/**/*.spec.ts |
| ESLint (flat config) | matches a plain assets/**/*.ts — no change needed |
| TypeScript | drops dot-prefixed segments from ** expansion — needs a SECOND explicit include: "assets/scripts/X/**/.tests/**/*.ts" |
Two traps when renaming an existing tests/, both of which fail silently:
- Grep for the directory-name string, not for the imports. Any self-exclusion filter keyed on the old name (
join('__tests__', …),path.startsWith('tests/')) quietly stops excluding, so a source-scanning spec scans itself; the ones reading source through a shared fixture import nofsto grep for. - Compare the file COUNT each tool actually processed, before and after — never the exit code.
tscsilently fell from 526 files to 337 with no exit-code change, on a project whose typecheck had been non-zero since its first commit. A chronically red gate hides a new regression exactly the way a false green does.
Measured 2026-08-31, Cocos Creator 3.8.7. Detail: docs/colocated-tests-cocos.md.
Testing reality
Section titled “Testing reality”There is no CLI build and no in-editor test runner for Cocos Creator 3.8.7 — engine-coupled behaviour (scenes, prefabs, node lifecycle, rendering) is verified manually in the Editor via t1k-cocos-tester’s checklists. That constraint applies to engine-coupled code only. Logic extracted per § “Feature modules & testability” is plain TypeScript and runs under Vitest on Node with no engine at all — which is precisely why the extraction is worth doing rather than an abstract nicety.
Note the kit’s own tooling (scripts/, skill helper scripts) uses node --test, not Vitest — that is kit-internal and separate from game-code testing.
Living Document
Section titled “Living Document”If unsure about a convention not covered here, ask the user for their preference and update this file with the answer. Conventions grow from real decisions.