Skip to content

Module structure conventions

One rule shapes every package:

txt
one autonomous element = one folder = one public index

A mechanic, a plugin, a storage backend — each lives in its own folder with a public index.ts, and is imported from its own package subpath. The structure scales by addition: new bricks get a new folder, and nothing else moves.

Folder anatomy

txt
packages/mechanics/src/
  index.ts            # package barrel (re-exports every mechanic)
  producers/
    index.ts          # the public surface of the mechanic
    extension.ts      # the Extension<T> factory
    types.ts          # public option/def/result types
  projects/
    index.ts
    extension.ts
    manager.ts        # ProjectManager — public via index.ts only
  crafting/
    index.ts
    extension.ts
    types.ts
    resources.ts      # ResourceBag helpers — public via index.ts only
    economy/
      index.ts        # optional bridge to @idlekitjs/economy (subpath-only)
  ...

packages/economy/src/
  index.ts            # main facade
  accessors/
    index.ts          # public subpath: @idlekitjs/economy/accessors
  cost-curves/
    index.ts          # public subpath: @idlekitjs/economy/cost-curves
  resources/
  amounts/
  requirements/
  transactions/
  format/

Internal files exist as the module needs them (registry.ts, manager.ts, scheduler.ts); no artificial empty files. The package.json exports map points each subpath at the folder's index.

Import rules

ts
// ✅ public subpaths — the preferred form
import { producers } from "@idlekitjs/mechanics/producers";
import { economyPurchase } from "@idlekitjs/mechanics/producers/economy";
import { projects, ProjectManager } from "@idlekitjs/mechanics/projects";
import { createEconomy, stateKey } from "@idlekitjs/economy";
import { geometric } from "@idlekitjs/economy/cost-curves";
import { autosave, SaveScheduler } from "@idlekitjs/plugins/autosave";
import { LocalStorageAdapter } from "@idlekitjs/storage/local-storage";

// ✅ the package barrel — fine for convenience
import { producers, projects } from "@idlekitjs/mechanics";
ts
// ❌ internal detail promoted to a subpath — does not exist
import { ProjectManager } from "@idlekitjs/mechanics/projectManager";
import { SaveScheduler } from "@idlekitjs/plugins/saveScheduler";

// ❌ deep import into a module's internals — not public API
import { producers } from "@idlekitjs/mechanics/producers/extension";

The principle behind both ❌ cases: helpers are public through the module that owns them, or not at all. ProjectManager belongs to the projects mechanic; SaveScheduler belongs to autosave; the ResourceBag helpers belong to crafting. Their home subpath is their only address — internals stay free to be reorganized without breaking anyone.

Economy bridges are a deliberate variant of the same rule. A bridge belongs to the mechanic whose state shape it adapts, so it is public at @idlekitjs/mechanics/<mechanic>/economy. It is not re-exported from the @idlekitjs/mechanics barrel and it does not live in @idlekitjs/economy/adapters. That keeps the core mechanic importable without Economy and prevents the cycle economy -> mechanics -> economy.

Import boundaries

Every export — value or type — has exactly one home package, and that is where you import it from:

ts
// The engine and its contracts
import { createEngine, SaveManager, type Extension, type EngineContext } from "@idlekitjs/core";

// DOM rendering: renderer and bindings
import { Renderer, bindText, type Binding } from "@idlekitjs/dom";

// Browser runtime bridges: frames, page lifecycle, screen info
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";
import { pageLifecycle } from "@idlekitjs/browser/page-lifecycle";

// Gameplay primitives: each mechanic owns its types
import { crafting, type RecipeDef, type CraftingJob } from "@idlekitjs/mechanics/crafting";
import { recipeCost } from "@idlekitjs/mechanics/crafting/economy";
import { boosts, type BoostDef, type ActiveBoost } from "@idlekitjs/mechanics/boosts";
import { createEconomy, type Transaction } from "@idlekitjs/economy";
import { projects, type Project } from "@idlekitjs/mechanics/projects";

// Plugins and storage backends own theirs too
import { autosave, type AutosaveOptions } from "@idlekitjs/plugins/autosave";
import { LocalStorageAdapter } from "@idlekitjs/storage/local-storage";

What @idlekitjs/core re-exports is deliberately limited to engine-level contracts (Extension, EngineContext, System, SaveAdapter, ... from @idlekitjs/types). It never re-exports mechanic, plugin, DOM, browser or storage types: Project comes from @idlekitjs/mechanics/projects, Binding from @idlekitjs/dom, createRafScheduler from @idlekitjs/browser/raf-scheduler, LocalStorageAdapter from @idlekitjs/storage/local-storage. Core is the engine's facade, not a catalog of everything IdleKit ships — and no deep imports, ever (see above).

Economy-owned types (ResourceDef, ResourceId, Transaction, Requirement, CostCurve) come from @idlekitjs/economy. They do not belong in core. Mechanic-owned bridge helpers (economyPurchase, projectFromTransaction, cardResources, recipeCost, activateBoost) come from their mechanic's /economy subpath.

The platform rule of thumb: writes to the DOM → dom; reads or bridges a browser API → browser; consumes only engine events → plugins.

Deliberate exceptions

  • @idlekitjs/core has internal sub-domains (loop/, save/, state/, events/, numbers/, random/, format/) but a single public facade. Core is one engine, not a bag of bricks — no public subpaths.
  • @idlekitjs/economy has a main facade plus two convenience public subpaths: @idlekitjs/economy/accessors and @idlekitjs/economy/cost-curves. Its internal domains (resources/, amounts/, requirements/, transactions/, format/) remain public via the main facade only.
  • @idlekitjs/dom has a main facade plus the @idlekitjs/dom/bind-each convenience subpath for keyed list binding.
  • @idlekitjs/utils and @idlekitjs/types are small, flat, single-facade packages.
  • @idlekitjs/browser follows the standard brick layout from day one (page-lifecycle/, raf-scheduler/, screen/).

Adding a new brick

  1. Create packages/<pkg>/src/<brick>/ with an index.ts exposing the public surface.
  2. Add "./<brick>": "./src/<brick>/index.ts" to the package's exports.
  3. Re-export from the package barrel, except for deliberate subpath-only bridges such as @idlekitjs/mechanics/<mechanic>/economy.
  4. Add packages/<pkg>/tests/<brick>.test.ts importing the public surface.
  5. Document it.

That is the whole procedure for a public brick.

Why it matters

  • Predictability — knowing one module's layout means knowing all of them.
  • Lean bundles — subpath imports pull one mechanic, not the catalog.
  • Safe refactors — internals aren't addressable, so they can change.
  • Package surface clarity — the exports map is the public API boundary.