Skip to content

Projects ​

One-shot (or repeatable) purchasable upgrades — the "research" pattern at the center of paperclips-style games. A project appears when its trigger fires, can be bought when affordable, applies its effect, and stays completed across saves.

ts
import { projects, ProjectManager } from "@idlekitjs/mechanics/projects";

Mental model ​

A Project<T> (exported by this mechanic) is fully declarative — the engine knows nothing about the nature of costs:

ts
interface Project<T> {
  id: string;
  title: string;
  description: string;
  trigger: (state: T) => boolean; // visible from when?
  affordable: (state: T) => boolean; // buyable right now?
  effect: (state: T) => void; // pay the cost AND apply the result
  cost?: (state: T) => string; // display label
  repeatable?: boolean; // stays available after purchase
}

effect both pays and applies — one function, so the mechanic can stay agnostic about your economy. If you use @idlekitjs/economy, the optional adapter maps an Economy transaction into this shape.

Minimal example ​

ts
import { projects, type Project, type ProjectsExtension } from "@idlekitjs/mechanics/projects";

interface State {
  ops: number;
  clickPower: number;
  completedProjects: string[];
}

const PROJECTS: Project<State>[] = [
  {
    id: "stronger-clicks",
    title: "Stronger clicks",
    description: "Double click power.",
    cost: () => "500 ops",
    trigger: (s) => s.ops >= 200,
    affordable: (s) => s.ops >= 500,
    effect: (s) => {
      s.ops -= 500;
      s.clickPower *= 2;
    },
  },
];

const projectsExt: ProjectsExtension<State> = projects<State>({
  projects: PROJECTS,
  getCompleted: (s) => s.completedProjects,
  setCompleted: (s, ids) => {
    s.completedProjects = ids;
  },
  onComplete: (_id, state) => void save.save(state), // save on progress
});

engine.use(projectsExt);

Buying and rendering ​

ts
projectsExt.buy("stronger-clicks", engine.state); // -> boolean

// Rendering: the manager lists what's visible right now
for (const project of projectsExt.manager.available(engine.state)) {
  // project.title, project.description, project.cost?.(engine.state)
  // enabled: project.affordable(engine.state)
}

buy refuses (returns false) for unknown ids, already-completed non-repeatable projects, and unaffordable ones — safe to wire straight to a button. On success it applies the effect, persists the completed list through setCompleted, and fires onComplete.

ProjectManager ​

The manager is the queryable core of the mechanic, exposed as projectsExt.manager (and importable for headless use):

MethodRole
available(state)Triggered and not-yet-completed projects (render these)
isCompleted(id)Completion check (for conditional content)
complete(id, state)Low-level buy (no state persistence — prefer ext.buy)
completedIds()The serializable completion list
restore(ids)Rebuild from a save (the extension does this on loaded)

Persistence ​

Completion is just a string[] in your state. On engine.load, the extension restores the manager from it (unknown ids are ignored, so removing a project from the catalog is safe). Repeatable projects are never marked completed — they reappear whenever their trigger holds.

Patterns ​

  • Story beats: a project whose effect flips a flag that triggers the next one — chains for free.
  • Prestige resets: a repeatable project whose effect rebuilds the state.
  • UI-friendly costs: cost is a display string on purpose — mixed-cost projects ("500 ops + 10 trust") need no special cost model.
  • Economy transactions: wrap requirements + cost + reward + apply with projectFromTransaction when you want transaction diagnostics and shared formatting.

Economy adapter ​

The optional bridge lives at @idlekitjs/mechanics/projects/economy:

ts
import { projectFromTransaction } from "@idlekitjs/mechanics/projects/economy";

It maps:

Project fieldEconomy source
affordableeconomy.canExecute(state, transaction)
effecteconomy.execute(state, transaction)
costformatted transaction cost unless overridden

Projects still own completed/repeatable/visible/trigger behavior.

ts
const betterPlows = projectFromTransaction(
  economy,
  {
    id: "project:better-plows",
    cost: [["currency:potatoes", 100]],
    apply: (state) => {
      state.upgrades.push("better-plows");
    },
  },
  {
    title: "Better plows",
    description: "Till twice the soil.",
  },
);

Common pitfalls ​

  • Forgetting the cost in effect. affordable only checks; effect must actually spend.
  • Non-stable ids. Completion persists by id — renaming one orphans old saves (the entry is dropped harmlessly, but the project reappears).
  • Heavy trigger predicates. available() evaluates triggers on every render call — keep them cheap reads.