Appearance
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):
| Method | Role |
|---|---|
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
effectflips a flag thattriggers the next one — chains for free. - Prestige resets: a repeatable project whose effect rebuilds the state.
- UI-friendly costs:
costis a display string on purpose — mixed-cost projects ("500 ops + 10 trust") need no special cost model. - Economy transactions: wrap
requirements + cost + reward + applywithprojectFromTransactionwhen 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 field | Economy source |
|---|---|
affordable | economy.canExecute(state, transaction) |
effect | economy.execute(state, transaction) |
cost | formatted 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.affordableonly checks;effectmust actually spend. - Non-stable ids. Completion persists by id — renaming one orphans old saves (the entry is dropped harmlessly, but the project reappears).
- Heavy
triggerpredicates.available()evaluates triggers on every render call — keep them cheap reads.
Related
- Quickstart — projects fit right after it.
- Economy adapters.
- Mechanics API — projects.