Skip to content

Skill: Code Style Conventions ​

Self-contained code conventions for this project. Load this file before writing or modifying code to ensure consistency with the codebase.


All conventions below are project-specific style choices, not MVT architectural requirements. Other codebases using MVT could use different conventions.

Naming Rules ​

ElementConventionExample
Fileslower-kebab-case.tsscore-model.ts, tile-kind.ts
Types / InterfacesPascalCaseScoreModel, GameViewBindings
Model typesSuffix with ModelScoreModel, PlayerInputModel
View functionsPascalCase, ending ViewHudView, ShipView
Bindings typesXxxViewBindingsHudViewBindings (never Props)
Functions / VariablescamelCasecreateScoreModel, deltaMs
Factory functionscreate + PascalCase nouncreateScoreModel, createSlotList
Boolean propertiesis / has / can prefixisAlive, hasAutoTurn, canFire
Query bindingsWhat they return, no getscore, screenX, isAlive
... with position/indexSuffix AttileKindAt(row, col)
... with a keySuffix ForcolorFor(kind)
Relay bindingson + what the user didonFirePressed, onTileTapped
Enum-like type namesUse Kind, not TypeTileKind not TileType
Lifecycle propertiesUse phase, not statephase: GamePhase not state: GameState
Unused parameters_ prefixupdate(_deltaMs: number)

File Naming ​

All file names use lower-kebab-case.ts:

score-model.ts    ✅
ScoreModel.ts     ❌
scoreModel.ts     ❌
score_model.ts    ❌

Formatting ​

  • 4 spaces for indentation (no tabs).
  • Enforced by ESLint Stylistic - run npm run lint:fix to auto-fix.

Barrel File Rules ​

Every directory under a package's src/ provides a barrel file (index.ts) that defines its public API.

  • Cross-directory imports: always go through index.ts (never past it).
  • Same-directory imports: use direct relative paths (./foo).
  • No .ts extensions in module specifiers - write './foo', not './foo.ts'.
  • No declarations in barrel files - only re-exports.
  • No self-imports through barrels, at any depth - not '.' or './index' from beside the barrel, and not '..' or '../index' from a subdirectory. A subdirectory that needs an ancestor's module imports its file directly ('../element-mixin').
ts
// ✅ Correct - import through barrel
import { ScoreModel } from './models';

// ❌ Wrong - reaching past the barrel
import { ScoreModel } from './models/score-model';

// ✅ Correct - within same directory, direct relative path
import { createTimerModel } from './timer-model';

// ❌ Wrong - self-import through an ancestor barrel, from a subdirectory
import { createTimerModel } from '..';

// ✅ Correct - from a subdirectory, the ancestor's file directly
import { createTimerModel } from '../timer-model';

Enforced by ESLint: import/no-internal-modules for reaching past a barrel, and no-restricted-imports for importing your own or an ancestor's.

Between packages (details):

  • Import a package by name (@mvtjs/pixi, @mvtjs/utils/jsx), never a path into it; its exports are its barrel.
  • Every import names a dependency of the nearest package.json; tests, scripts and config may use devDependencies (import/no-extraneous-dependencies).
  • In the site and benchmarks, take SKIP_DESCENDANTS, hasUpdate, hasRefresh, the counters and the method types from the renderer package, not @mvtjs/utils (no-restricted-imports).
  • packages/website/src/playground/ and the rest of the site never import each other.

String-Literal Unions ​

Use unions of string literals for enum-like types. Never use TypeScript enum or const-object patterns:

ts
// ✅ Preferred
type TileKind = 'empty' | 'wall' | 'dot' | 'spawn-point';

// ❌ Avoid
const TileType = { Empty: 0, Wall: 1 } as const;
type TileType = (typeof TileType)[keyof typeof TileType];

// ❌ Avoid
enum TileType { Empty, Wall, Dot }

No null ​

Use undefined throughout. Aligns with JavaScript's own APIs:

ts
// ✅ Preferred
function find(id: string): Item | undefined;
let selected: Item | undefined;

// ❌ Avoid
function find(id: string): Item | null;
let selected: Item | null = null;

View Functions ​

A view is a function XxxView(bindings: XxxViewBindings): Container, usable as a JSX tag and as a plain call. A top-level view takes the model in its bindings: GameView({ model }). The body may be JSX (.tsx) or plain TypeScript (.ts), whichever suits the view; neither is required. Each query binding's type says what the view supports: () => T for changing state, T for a value read once at construction, ValueOrGetter<T> (from @mvtjs/pixi) for either. Never declare a function and read it only once. Full rules: Style Guide: Views and Bindings; how to write one: skill-mvt-view.md.

No Classes ​

Use factory functions returning plain records that satisfy an interface. Private state lives in the closure:

ts
// ✅ Preferred
interface CounterModel {
    readonly count: number;
    increment: () => void;
    update: (deltaMs: number) => void;
}

function createCounterModel(): CounterModel {
    let count = 0;

    const model: CounterModel = {
        get count() { return count; },
        increment() { count += 1; },
        update(_deltaMs) { /* ... */ },
    };

    return model;
}

// ❌ Avoid
class CounterModel {
    private count = 0;
    increment() { this.count += 1; }
    update(deltaMs: number) { /* ... */ }
}

A factory takes one options object, required inputs included: createJsx({ target, elements }), never createJsx(target, elements). Ordered parameters are fragile, and cannot grow without breaking callers. See Style Guide: Factory Functions.

Function-Valued Properties in Types ​

In interfaces and type declarations (models, bindings, options), write function members as properties holding a function, never with method syntax:

ts
// ✅ Preferred
interface ToolbarViewBindings {
    selectedTool: () => ToolKind;
    onToolPressed?: (tool: ToolKind) => void;
}

// ❌ Avoid
interface ToolbarViewBindings {
    selectedTool(): ToolKind;
    onToolPressed?(tool: ToolKind): void;
}

Method signatures get looser parameter checks, even in strict mode, and suggest a this-bound method, which this project never has. Object literals implementing the interface may still use method shorthand. Enforced by lint.

Assertions ​

State preconditions, postconditions and invariants with assert from @mvtjs/utils (assert(loaded, 'call load() first')), not if (...) throw. Pass a message built from values as a function, so it is built only on failure. Not on hot paths: in code that runs every frame, keep a plain if and throw. Costly checks go under the caller's if (DEV). See Style Guide: Assertions.

Easily Confused Names ​

AvoidPreferRationale
typekindConfused with the TypeScript type keyword
statephase, status, mode, or a domain-specific nameEvery property on a model is "state" - confusingly meta

Boolean Properties ​

Boolean properties and accessors should read as yes/no questions:

PrefixWhen to useExample
isState or condition (default choice)isAlive, isActive, isThrusting
hasOwnership or presencehasAutoTurn, hasShield
canCapability or permissioncanFire, canClick, canMove

File Sections ​

Model and view files use section dividers for navigability:

// --- Interface ---           (views: Bindings)
// --- Options (if needed) ---
// --- Factory ---             (views: View)
// --- Internals (if needed) ---

Readers see the public contract first, then configuration, then implementation, then internals.

Declaration Order Within Functions ​

Within factory functions, follow big-picture-first ordering:

  1. Initialisation at the top - set up state and child models.
  2. Public record - the returned object with its methods.
  3. Return statement.
  4. Child construction helpers - functions that build sub-components.
  5. Low-level helpers - small utilities, math functions.

JavaScript's function hoisting makes this possible - declare functions in conceptual order, not call-before-definition order.

Full Reference ​