Skip to content

code-conventions-cocos

Code Conventions — Cocos Creator / TypeScript

Section titled “Code Conventions — Cocos Creator / TypeScript”

Extends core code-conventions.md. The1Studio-specific patterns.

  • 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 I prefix (modern TS style, not Java/C#)
  • Callbacks: on* or handle* prefix — onWeaponPlaced, handleTimerEnd
  • Constants: UPPER_SNAKE_CASE in config objects
  • Files: {Feature}{Suffix}.ts — ColorService.ts, TimerComponent.ts
  • Service: business logic, singleton — reusable utilities
  • Component: UI, extends Component — scene-attached
  • Controller: orchestration, state management
  • Singleton: private static _instance + static get instance(), set in onLoad()
  • Use signalBus.subscribe/unsubscribe() for inter-component events — NOT direct calls
  • MUST use named methods (arrow function class fields), NOT inline lambdas
  • Reason: unsubscribe() uses indexOf() — lambdas create new refs each call
  • Unsubscribe in onDestroy() — always clean up
  • @ccclass('ClassName') on every component class
  • @property(Type) for editor-exposed fields only
  • Destructure at top: const { ccclass, property } = _decorator;
  • Guard clauses for null safety — early return, not nested conditions
  • readonly for config values — prevents runtime mutation
  • Extract magic numbers to GameConfig or local const
  • 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...of over 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”.

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 export

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:

  1. 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-arch references/testing-strategy.md § “Test structure” for the doctrine, but note it names a plain tests/; 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 as tests/ 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.
  2. Game logic lives outside cc.Component. Rules, formulas, state transitions and validation go in plain TS classes that the @ccclass layer drives. A @ccclass node script that owns game rules cannot be tested without a scene load. Enforcement + the ESLint no-restricted-paths config are in t1k-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.

ToolWith 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
TypeScriptdrops 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 no fs to grep for.
  • Compare the file COUNT each tool actually processed, before and after — never the exit code. tsc silently 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.

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.

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.