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.