Pardon our dust! All this is still a work in progress.

Param

Reactive parameters for the task graph

A Param is a small reactive container you can:

  • read (get())
  • update (set(cb))
  • observe (subscribe(cb))

Params are usually the inputs to tasks. When a param changes, dependent tasks become dirty and will recompute on the next g.run().

Creating a Param

import { param } from "@hello-terrain/work";

const count = param(0);
const name = param("hello");
const config = param({ debug: true, maxRetries: 3 });

param(initial) infers the type from the initial value and returns a ParamRef<T>.

API Reference

param(initial)

Creates a new reactive parameter.

ParameterTypeDescription
initialTThe initial value of the parameter

Returns: ParamRef<T>

ParamRef Interface

get()

Returns the current value of the parameter.

const width = param(100);
console.log(width.get()); // 100

set(callback)

Updates the parameter value using a callback function. The callback receives the previous value and returns the new value.

const count = param(0);

// Increment
count.set((prev) => prev + 1);

// Replace entirely
count.set(() => 42);

// Chain updates (fluent API)
count
  .set((prev) => prev + 1)
  .set((prev) => prev * 2);
ParameterTypeDescription
callback(prev: T) => TFunction that receives current value and returns new value

Returns: ParamRef<T> (for chaining)

subscribe(callback)

Subscribes to value changes. The callback is invoked whenever the param is updated via set().

const theme = param("light");

const unsubscribe = theme.subscribe((next, prev) => {
  console.log(`Theme changed from ${prev} to ${next}`);
});

theme.set(() => "dark");
// Logs: "Theme changed from light to dark"

// Clean up when done
unsubscribe();
ParameterTypeDescription
callback(next: T, prev: T) => voidFunction called on each value change

Returns: () => void - Unsubscribe function

displayName(name)

Assigns a human-readable name for debugging and visualization.

const playerHealth = param(100).displayName("playerHealth");
console.log(playerHealth.name); // "playerHealth"
ParameterTypeDescription
namestringDisplay name for the parameter

Returns: ParamRef<T> (for chaining)

Properties

PropertyTypeDescription
kind"param"Type discriminator for distinguishing params from tasks
idstringUnique identifier (UUID)
namestring | undefinedDisplay name if set

Examples

Counter

const count = param(0).displayName("count");

// Read
console.log(count.get()); // 0

// Update
count.set((n) => n + 1);
console.log(count.get()); // 1

// Subscribe
count.subscribe((next, prev) => {
  console.log(`Count: ${prev} → ${next}`);
});

count.set((n) => n + 1); // Logs: "Count: 1 → 2"

Configuration Object

interface Config {
  apiUrl: string;
  timeout: number;
  retries: number;
}

const config = param<Config>({
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
}).displayName("config");

// Update a single field
config.set((prev) => ({
  ...prev,
  timeout: 10000,
}));

// Read current config
const current = config.get();
console.log(current.timeout); // 10000

Using with Tasks

Params are the primary way to inject changing input values into a task graph:

import { param, task, graph } from "@hello-terrain/work";

const basePrice = param(100).displayName("basePrice");
const taxRate = param(0.08).displayName("taxRate");

const totalPrice = task((get, work) => {
  const base = get(basePrice);
  const tax = get(taxRate);
  return work(() => base * (1 + tax));
}).displayName("totalPrice");

const g = graph();
g.add(totalPrice);

await g.run();
console.log(g.get(totalPrice)); // 108

// Update input and re-run
taxRate.set(() => 0.10);
await g.run();
console.log(g.get(totalPrice)); // 110

Graph-Scoped Params

By default, a param holds a single shared value. When a task calls get(param), it reads that shared value and the graph subscribes to changes via param.set().

For multi-instance use cases (e.g. two terrain meshes in the same scene), you need each graph to have its own copy of a param's value. graph.set() does exactly this — it takes graph-local ownership of a param, storing an isolated value that only that graph sees.

Declaring params at module scope

// params.ts — shared declarations with sensible defaults
import { param } from "@hello-terrain/work";

export const rootSize = param(256);
export const maxLevel = param(16);

Using graph.set() for per-instance values

import { graph, task } from "@hello-terrain/work";
import { rootSize, maxLevel } from "./params";

const configTask = task((get, work) => {
  const size = get(rootSize);
  const level = get(maxLevel);
  return work(() => ({ size, level }));
}).displayName("configTask");

// Graph 1 — own values
const g1 = graph()
  .add(configTask)
  .set(rootSize, () => 128)
  .set(maxLevel, () => 12);

// Graph 2 — different values, same param tokens
const g2 = graph()
  .add(configTask)
  .set(rootSize, () => 512)
  .set(maxLevel, () => 20);

await g1.run();
await g2.run();

console.log(g1.get(configTask)); // { size: 128, level: 12 }
console.log(g2.get(configTask)); // { size: 512, level: 20 }

How ownership works

  • Before graph.set() is called, the graph subscribes to param.subscribe() — exactly like today.
  • The first graph.set(param, cb) call detaches the external subscription and stores a graph-local value.
  • Subsequent graph.set() calls update the graph-local value and mark downstream tasks dirty.
  • External param.set() calls no longer affect a graph that has taken ownership.

You can mix owned and unowned params in the same graph. Only params you explicitly graph.set() become graph-local; the rest continue to use the shared subscription flow.

Type Safety

Params are fully typed—the type is inferred from the initial value or can be explicitly specified:

// Type inferred as ParamRef<number>
const count = param(0);

// Explicit type annotation
const items = param<string[]>([]);

// Complex types
interface User {
  id: string;
  name: string;
}
const currentUser = param<User | null>(null);