Skip to content

Style Guide

Code conventions for this project: naming, formatting, file structure, enumeration types, and declaration order. These are style choices specific to this codebase, not MVT architectural requirements.

Related: Architecture Rules · Project Structure · Glossary


For MVT architectural rules (models own state, views are stateless, etc.), see Architecture Rules. This page covers how code is written and organized in this project.

Quick Reference

ConventionExampleSection
File namesscore-model.tsFile Naming
Types / interfacesScoreModel, TileKindNaming Conventions
Functions / variablescreateScoreModel, deltaMsNaming Conventions
Factory functionscreateXxxModel(options)Factory Functions
Binding accessorsgetScore(), onResetClicked()Naming Conventions
Enum-like typestype TileKind = 'wall' | 'empty'Enumeration Types
Clear namesKind not Type, phase not stateEasily Confused Names
Barrel importsimport { Foo } from './module'Project Structure
Module specifiers'./foo' not './foo.ts'Project Structure
Indentation4 spacesFormatting
Unused parameters_deltaMsNaming Conventions

Naming Conventions

ElementConventionExample
Fileslower-kebab-case.tsscore-model.ts, tile-kind.ts
Types / InterfacesPascalCaseScoreModel, GameViewBindings
Model typesSuffix with ModelScoreModel, PlayerInputModel
View typesSuffix with ViewMazeView, KeyboardPlayerInputView
Functions / VariablescamelCasecreateScoreModel, deltaMs
Factory functionscreate + PascalCase nouncreateScoreModel, createHudView
Boolean propertiesis / has / can prefixisAlive, hasAutoTurn, canFire
Binding accessorsget + descriptiongetScore(), getEntityX()
Binding event handlerson + descriptiononDirectionChanged(), onResetClicked()
Enum-like type namesUse Kind, not TypeTileKind ✅ · TileType
Lifecycle propertiesUse phase, not statephase: GamePhase ✅ · state: GameState
Unused parameters_ prefixupdate(_deltaMs: number)

Boolean Properties

Boolean properties and accessors should read as yes/no questions. Prefer the is prefix as the default; use has or can when they fit the semantics better:

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

When in doubt, try rewording the property name so that is works. Prefer isAlive over alive, isFuelEmpty over fuelEmpty.

ts
// ✅ Preferred - reads as a question
readonly isAlive: boolean;
readonly hasAutoTurn: boolean;
readonly canFire: boolean;

// ❌ Avoid - bare adjective / noun
readonly alive: boolean;
readonly autoTurn: boolean;
readonly fuelEmpty: boolean;

File Naming

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

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

Formatting

  • Use 4 spaces for indentation (no tabs).
  • Formatting is enforced by ESLint Stylistic - run npm run lint:fix to auto-fix, or npm run lint to verify without fixing.

Enumeration Types

Use unions of string literals rather than const-object patterns or TypeScript enum declarations. Use Kind in type names, not Type.

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

// ❌ Avoid - const-object enum pattern
const TileType = { Empty: 0, Wall: 1, Dot: 2 } as const;
type TileType = (typeof TileType)[keyof typeof TileType];

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

Why string literals?

  • Simple and type-safe
  • Self-documenting in logs and debugger output ('wall' vs 1)
  • Work naturally with switch statements and discriminated unions

Easily Confused Names

Some common English words carry a well-known meaning in programming. When these words appear as identifier names with a different meaning, readers pause to disambiguate. Avoid these in favour of more precise alternatives.

AvoidPreferRationale
typekindEasily confused with the TypeScript type keyword and typeof.
statephase, status, mode, or a domain-specific nameEvery property on a model is "state." A property called state is confusingly meta. Use phase for lifecycle stages, status for conditions, mode for operational modes.
ts
type EnemyType = 'pooka' | 'fygar'; // ❌ clashes with the TS concept of a type
type EnemyKind = 'pooka' | 'fygar'; // ✅ clearly means which kind of enemy

type GameState = 'idle' | 'playing' | 'gameover'; // ❌ confusingly meta
type GamePhase = 'idle' | 'playing' | 'gameover'; // ✅ clearly means lifecycle stage

General principle: if a word already has a common meaning in the codebase or language and your intended meaning is different, choose a word that does not require the reader to disambiguate.

No null

Prefer undefined over null throughout the codebase to align with JavaScript's own APIs (which consistently use undefined).

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

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

Factory Functions

This project uses factory functions and plain records instead of classes. This is a project convention, not an MVT requirement.

  • Define each model/view as a pure interface describing its public API.
  • Expose a factory function (createXxx) that accepts an options object and returns an instance of the interface type.
  • Implement as plain records satisfying the interface. Use closure scope for private state.
ts
interface CounterModel {
    readonly count: number;
    readonly rate: number;
    increment(): void;
    update(deltaMs: number): void;
}

interface CounterModelOptions {
    readonly initialCount?: number;
    readonly rate?: number;
}

function createCounterModel(options: CounterModelOptions = {}): CounterModel {
    const { initialCount = 0, rate = 1 } = options;
    let elapsed = 0;

    const model: CounterModel = {
        count: initialCount,
        rate,
        increment() {
            (model as { count: number }).count += 1;
        },
        update(deltaMs) {
            elapsed += deltaMs;
        },
    };

    return model;
}

Key points:

  • The interface is the public contract - exported and referenced by other code.
  • The options object makes factories extensible without breaking call sites.
  • Private state (elapsed) lives in the closure, invisible to consumers.
  • readonly properties signal "read from outside, mutate only from within."

Code Organisation

File Sections

Each model or view file follows a consistent internal structure using section dividers for navigability:

ts
// ---------------------------------------------------------------------------
// Interface
// ---------------------------------------------------------------------------

// Public interface definition

// ---------------------------------------------------------------------------
// Options (if needed)
// ---------------------------------------------------------------------------

// Options type for the factory function

// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------

// createXxx() factory function implementation

// ---------------------------------------------------------------------------
// Internals (if needed)
// ---------------------------------------------------------------------------

// Internal types, constants, and helpers used only inside this file

The ordering is deliberate - readers see the public contract first (interface), then the configuration surface (options), then the implementation (factory), and finally internals at the bottom.

All exports (types, interfaces, factory functions) go above all internals. Internal declarations - both types and runtime values (constants, helper functions) - belong at the bottom, below every exported symbol.

Type ordering within exported sections:

  • Main types before helper types. If an exported type references another exported helper type (e.g. GameModel.particles: DebrisParticle[]), the main type appears first, then the helper type it composes.
ts
// ✅ Correct - main interface first, helper type second, internal type last
export interface DebrisModel {
    readonly particles: readonly DebrisParticle[];
    /* ... */
}

export interface DebrisParticle {
    readonly x: number;
    /* ... */
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

interface MutableParticle { /* ... used only inside the factory ... */ }

Declaration Order Within Functions

Within factory functions and other non-trivial functions, follow a big-picture-first ordering:

  • Exports and public API at the top - the returned record, public interface, and main flow.
  • High-level helpers in the middle - the major building blocks called by the public API.
  • Low-level / private helpers at the bottom - small utilities, math functions, and internal details.

This mirrors the file-level convention (interface before factory) and lets readers understand the function's purpose without scrolling. JavaScript's function hoisting makes this possible - declare functions in conceptual order, not in call-before-definition order.

ts
export function createGameModel(options: GameModelOptions): GameModel {
    const { arenaWidth, arenaHeight } = options;

    // --- Initialise ---------------------------------------------------------
    const ship = buildShip();
    let asteroids: AsteroidModel[] = [];

    // --- Public record ------------------------------------------------------
    const model: GameModel = {
        get ship() { return ship; },
        get asteroids() { return asteroids; },
        update(deltaMs: number): void { /* main loop */ },
    };

    return model;

    // --- Child construction -------------------------------------------------

    function buildShip(): ShipModel { /* ... */ }

    // --- Helpers ------------------------------------------------------------

    function distSq(x1: number, y1: number, x2: number, y2: number): number {
        /* ... */
    }
}