Skip to content
Moku Labs
@moku-labs/game pages

Lint

@moku-labs/game/lint: the oxlint rules that keep a game deterministic, lazy, and on its layered layout.

On this page

Setup

@moku-labs/game/lint is an oxlint JS plugin named moku-game. The rules use the ESLint rule API, so ESLint loads the same object. The engine ships no default severity: a game turns each rule on.

.oxlintrc.json
{
  "jsPlugins": ["@moku-labs/game/lint"],
  "rules": {
    "moku-game/lazy-imports": "error",
    "moku-game/native-imports": "error",
    "moku-game/dev-imports": "error",
    "moku-game/no-module-state": "error",
    "moku-game/determinism": "error",
    "moku-game/rules-siblings": "error",
    "moku-game/static-keys": "error",
    "moku-game/layer-imports": "error",
    "moku-game/feature-door": "error",
    "moku-game/sub-features": "error",
    "moku-game/test-suffix": "error"
  }
}

The engine rules

RuleReportsPasses
lazy-importsA static value import of pixi.js or yoga-layout, subpaths too.import type …, await import("pixi.js").
native-imports@moku-labs/system, @moku-labs/native, @tauri-apps/* in the logic, the root index.ts, config.ts, kit.ts and plugins/.Name the capability in config.ts system; the engine page wires it.
dev-imports@moku-labs/editor and @moku-labs/game/control outside dev files.The same import in *.dev.ts(x), a test or the generated .moku/ page.
no-module-stateA module-scope let or var, a module-scope new Map/Set/WeakMap/WeakSet.The same inside a function.
determinismMath.random, Date.now, performance.now, new Date(), setTimeout, setInterval in the logic.new Date(now): it formats a stored moment. Plugin folders are the effect side and are skipped.
rules-siblingsAn import in rules/ that is not a ./ sibling, @core/types or @shared/rules.import { cost } from "./cost".
static-keysA JSX key the project index cannot follow.A literal, a template, props.id, props.<name>Key, a call.

import { type A } from "pixi.js" is reported: under verbatimModuleSyntax it keeps a side-effect import, which loads Pixi. Write import type { A } from "pixi.js".

Static keys

The game reports a JSX key at run time, and the project index turns it back into the line that wrote it. That works when the key is written so the index can read it.

views/tab-reported.tsx
/** @jsxImportSource @moku-labs/game */
type Tab = "audio" | "language";

const tabKeys = { audio: "tabSound", language: "tabLanguage" };

export function TabButton(props: { tab: Tab }) {
  const key = tabKeys[props.tab];
  return <button key={key} intent="openTab" />;
}
  1. Reported: the key is picked out of a table at run time.
views/tab-passes.tsx
/** @jsxImportSource @moku-labs/game */
type Tab = "audio" | "language";

export function TabButton(props: { id: string; tab: Tab }) {
  return <button key={props.id} intent="openTab" payload={{ tab: props.tab }} />;
}

export const soundTab = <TabButton id="tabSound" tab="audio" />;
  1. Passes: the key comes in as a key-carrying prop, id or a name that ends in Key.

The message is Keys: a JSX key must be a literal, a template, or props.id / props.<name>Key. Pass the key in as a prop: <X id="…">.

The layout rules

Four rules check a game on the layered layout. Each path below the game root sits in one layer:

PathLayer
core/**core
shared/**shared
features/<f>/**, features/<f>/<sub>/**features
plugins/<p>/**plugins
generated/**generated
game.tsgame
tests/**tests

A file outside them, such as kit.ts, nodes/ or web/, keeps the layout rules quiet. So a game on a flat layout lints as before.

RuleSays
layer-importsThe order is core ← shared ← features ← game.ts; plugins import core and shared; every layer may import generated/. import type counts too.
feature-doorAnother feature is imported only through its index.ts, @features/orders. Only game.ts imports the @features and @plugins barrels. A relative import that leaves a feature is written as an alias.
sub-featuresA feature's door imports every sub-feature from ./<folder> and lists it in features: [...]. A sub-feature's door registers a feature named like its folder.
test-suffixA file in a test kind folder ends with its suffix: tests/e2e/ .e2e.ts, __tests__/isolated/ .isolated.ts, and so on. See Testing.

A sub-feature never imports the feature it lives in: Door: a sub-feature does not import the feature it lives in: features/episodes/ep01 imports features/episodes. Move what they share into a sibling sub-feature, such as features/episodes/episodes-kit, and import it from its door.

Aliases

layer-imports, feature-door and rules-siblings read the compilerOptions.paths of the game's tsconfig.json, the way the project index does, extends included. Without paths they read the default table:

AliasTarget
@core/*core/*
@sharedshared/index.ts
@shared/rulesshared/rules/index.ts
@featuresfeatures/index.ts
@features/*features/*/index.ts
@pluginsplugins/index.ts
@generated/*generated/*
@tests/*tests/*

aliasTargetsOf(tsconfig, root, specifier) answers what the rules read for one specifier:

alias.ts
import { aliasTargetsOf } from "@moku-labs/game/lint";

aliasTargetsOf("/game/tsconfig.json", "/game", "@features/orders"); // ["/game/features/orders/index.ts"] for a game without a tsconfig

The * of @features/* crosses /: @features/episodes/ep01 is the door of a sub-feature.

Options

Every rule takes files and ignores. A key you give replaces that default. Globs are relative to the directory oxlint runs in.

.oxlintrc.json
{
  "rules": {
    "moku-game/determinism": ["error", { "files": ["src/logic/**"], "ignores": ["**/*.dev.ts"] }],
    "moku-game/layer-imports": ["error", { "root": "src", "tsconfig": "tsconfig.game.json" }],
    "moku-game/test-suffix": ["error", { "suffixes": { "tests/e2e/": ".spec.ts" } }]
  }
}
OptionRulesMeaning
files, ignoresevery ruleGlobs of the files the rule checks or skips.
rootthe layout rules, rules-siblings, test-suffixThe folder of core/, shared/, features/, plugins/ and game.ts. Default ..
tsconfiglayer-imports, feature-door, sub-features, rules-siblingsThe tsconfig whose paths name the aliases. Default tsconfig.json.
suffixestest-suffixKind folder to suffix. Replaces the default table whole.

The logic, where determinism and native-imports look by default, is the root index.ts, **/state.ts, **/tables.ts, **/game.ts and **/{core,nodes,flows,rules,features,shared}/**. Tests (tests/**, **/__tests__/**, *.test.ts) are skipped by every rule but test-suffix.

  • Turn each rule on. The plugin ships no default severity.
  • A lazy import("pixi.js") is fine. oxlint's own no-restricted-imports would flag it, which is why these rules ship as plugin code.
  • Plugins answer effects. determinism skips plugin folders: a plugin answers schedule with a timer by design.