Documentation
Browse documentation

DocumentationWrite a widget

The logic

logic.ts is the code of a PlayerVox OverCrow widget: it owns the widget's state, exports the functions the view calls, and talks to OverCrow through @overcrow/sdk. It is one TypeScript (or JavaScript) module, bundled with the SDK into the package's only script, logic.js.

TStemplates/counter/logic.ts
// A counter. The view reads `state.count` and calls `increment` and
// `decrement` on activation; after each handler the VM renders the view
// again and sends only what changed.
import { initState } from "@overcrow/sdk";

declare module "@overcrow/sdk" {
  interface WidgetState {
    count: number;
  }
}

const state = initState({ count: 0 });

export function increment(): void {
  state.count += 1;
}

export function decrement(): void {
  state.count -= 1;
}

That is a complete logic module: a state with one member, and two handlers that the view calls with on:activate={increment}.

How the logic runs

  • The module runs once, when the widget starts, in its own JavaScript VM inside a sandboxed process. It is not a browser and not Node.js: there is no DOM, window, fetch, setTimeout, console, Intl or require. The standard ECMAScript objects are there (Math, JSON, Date, Map, Promise…), and the SDK is the only way out.
  • Everything after that happens in turns. PlayerVox OverCrow sends an event, a timer tick, a service answer or a change of its own data; your code runs; then the VM evaluates the view again and sends OverCrow only what changed. You never update the view by hand.
  • A turn has a time budget, and the VM a memory ceiling. A widget that exceeds one is stopped and restarted by PlayerVox OverCrow, a few times; see what happens on a failure.

The only import a logic module may have is @overcrow/sdk. Everything else it needs is in the module itself.

The state

state is a plain object. Declare the type of its members once, by extending the SDK's WidgetState interface, give it its first value with initState, and assign to it from then on.

TStemplates/list/logic.ts
interface Item {
  id: number;
  text: string;
  done: boolean;
}

declare module "@overcrow/sdk" {
  interface WidgetState {
    items: Item[];
  }
}

const state = initState({
  items: [
    { id: 1, text: t("first"), done: false },
    { id: 2, text: t("second"), done: false },
    { id: 3, text: t("third"), done: true },
  ] as Item[],
});

After each turn the VM evaluates the whole view again and sends the differences. There is nothing to subscribe to and no setter to call: changing a member in place and replacing it both work.

TStemplates/list/logic.ts
export function setDone(id: number, done: unknown): void {
  state.items = state.items.map((item) => (item.id === id ? { ...item, done: done === true } : item));
}

Keep in the state what the view shows, and in ordinary variables what only the logic needs (a timer, a subscription). A value in the state must be plain data: numbers, strings, booleans, null, arrays and objects of those.

Functions for the view

The view can call only what the logic module exports by name:

  • in an expression, a function that returns what to show: {remaining(state.items)};
  • as a handler, a function that changes the state: on:activate={remove(item.id)}.
TStemplates/list/logic.ts
export function remove(id: number): void {
  state.items = state.items.filter((item) => item.id !== id);
}
TStemplates/list/logic.ts
export function remaining(items: readonly Item[]): number {
  return items.filter((item) => !item.done).length;
}

Expression functions run every time the view is evaluated: keep them cheap and without side effects. A handler named without arguments (on:change={setRepeat}) receives the event's detail:

TSdocs/content/examples/countdown/logic.ts
export function setRepeat(event: { value: unknown }): void {
  state.repeat = event.value === true;
}

export default is refused: the view calls named exports. t needs no export: a view that calls t(…) gets the SDK's, unless the logic exports its own.

Data from OverCrow: host

host holds what PlayerVox OverCrow knows about the user and the widget. Its members always read the latest value.

MemberValue
host.localeInterface language, "en" or "fr".
host.theme"dark" or "light".
host.mode"passive" or "interactive": the widget receives input only in Interactive mode.
host.visibleWhether the widget is shown.
host.scaleContent scale in thousandths: 1000 is 100 %.
host.viewport{ width, height } of the widget's content, in logical pixels.
host.regionThe user's regional formats and UTC offset; the formatting helpers read it.
host.optionsValues of the options menu; read them with option.
host.grantsThe capabilities the user granted; read it with hasGrant.
host.messagesThe messages of the active language; read them with t.

onHost(listener) calls your listener when PlayerVox OverCrow sends new values, with the names of the members that changed, before the view is evaluated again. Use it when a change needs more than showing the new value: re-arm a timer, subscribe again, redraw a canvas. A member may be sent again with the same value: compare it with the one you kept when that matters.

TSwidgets/stopwatch/logic.ts
onHost((changed) => {
  if (changed.includes("mode")) {
    state.interactive = host.mode === "interactive";
  }
});

Most widgets only show the buttons that work: in Passive mode a click goes through the widget to the game, so the stopwatch hides its buttons unless host.mode is "interactive".

The options menu

The rows declared in wrapper.menu of the manifest are drawn and stored by PlayerVox OverCrow. The logic reads them:

  • option(id, fallback) returns the stored value of a toggle, slider or choice row, or fallback when there is none or it has another type;
  • onHost reports "options" when the user changes a row;
  • onMenu(handler) receives the ID of an action row when the user chooses it.
TSwidgets/clock/logic.ts
export function menuValues(): { seconds: boolean; showDate: boolean; order: DateOrder } {
  return {
    seconds: option("show-seconds", false),
    showDate: option("show-date", true),
    order: DATE_ORDERS[option("date-format", "day-month-year")] ?? "dmy",
  };
}
TSdocs/content/examples/countdown/logic.ts
onMenu((row) => {
  if (row === "reset-rounds") {
    resetRounds();
  }
});

Choosing a menu row is not a user action for a protected service: an action row can change the state, not write to the clipboard.

Timers

There is no setTimeout. Timers are PlayerVox OverCrow's, through timers:

FunctionCalls back
timers.after(ms, callback)once, after ms
timers.every(ms, callback)every ms
timers.atEach(unit, callback)at each "second", "minute", "hour" or "day" of the user's local time

Each returns a Timer with cancel().

TStemplates/chart/logic.ts
timers.every(1000, () => {
  const previous = state.values.at(-1) ?? 50;
  state.values = [...state.values.slice(1 - POINTS), sample(previous)];
});

Three rules follow from PlayerVox OverCrow owning the timers:

  • No timer shorter than 100 ms. A shorter interval is raised to it. Motion belongs to the style, not to timers.
  • A hidden widget does not tick. A repeating timer skips the ticks that fell while the widget was hidden; a one-shot timer that fell due while hidden ticks once when the widget is shown again. Compute elapsed time from Date.now(), never from a count of ticks:
TSdocs/content/examples/countdown/logic.ts
// A timer does not tick while the widget is hidden: the time left comes
// from the clock, not from the number of ticks.
function tick(): void {
  state.remainingMs = Math.max(0, endsAt - Date.now());
  if (state.remainingMs > 0) {
    return;
  }
  stop();
  setRounds(state.rounds + 1);
  log.info(`round ${state.rounds} finished`);
  if (state.repeat) {
    start();
  }
}
  • At most 16 timers at once.

For a clock, timers.atEach is the right tool: one repeating timer aligned on the boundaries of local time, which also wakes when the UTC offset changes (summer time) and once when the widget is shown again.

TSwidgets/clock/logic.ts
/** Ticks at each second or minute of local time, and shows the time now. */
function start(): void {
  clock?.cancel();
  clock = timers.atEach(state.seconds ? "second" : "minute", tick);
  tick();
}

A duration that only has to advance on screen needs no timer at all: the elapsed element is advanced by PlayerVox OverCrow from a starting point the logic gives it. See the elapsed element.

Time, dates and numbers

Inside the VM, local time is UTC: Date never gives the user's time, and there is no Intl. The SDK formats instead, from host.region:

HelperGives
formatTime(ms, { seconds })14:08, or 14:08:42: 24-hour, in the user's time zone
formatDate(ms, { order })17/07/2026, 07/17/2026 or 2026-07-17, in the user's date order by default
formatNumber(value, options)1,234.5, 1 234,5 or 1.234,5: the user's separators, whatever the interface language
formatDuration(ms, options)04:05, 1:02:03, or 04:05.67 with hundredths
localTime(ms)the fields of the instant in the user's time zone: year, month, day, weekday, hour…
TSwidgets/clock/logic.ts
export function clockTime(now: number, seconds: boolean): string {
  return formatTime(now, { seconds });
}
TStemplates/chart/logic.ts
export function latest(values: readonly number[]): string {
  const last = values.at(-1);
  return last === undefined ? "" : formatNumber(last, { maximumFractionDigits: 0 });
}

A timestamp that comes from a service carries its own offsetMinutes: pass it to the helper (formatTime(at, { offsetMinutes })), so that a session recorded in another time zone shows the time it had there. Month and weekday names and 12-hour clocks are not provided.

Messages

Put the texts of the widget in locales/en.json and locales/fr.json: two flat objects of strings with the same keys. Both files or neither.

JSONtemplates/list/locales/en.json
{
  "first": "Warm up",
  "remaining": "{count} left",
  "remove": "Remove",
  "second": "Play three matches",
  "third": "Update the drivers",
  "title": "Today"
}

t(key, params) returns the message of the user's language and replaces each {name} by params.name; a missing key gives the key itself. It works in the view and in the logic alike:

OCMLtemplates/list/view.ocml
  <text class="summary">{t("remaining", { count: remaining(state.items) })}</text>
TSwidgets/stopwatch/logic.ts
export function toggleLabel(running: boolean): string {
  return t(running ? "pause" : "start");
}

When the user changes PlayerVox OverCrow's language, the view is evaluated again with the new messages. A text that the logic computed and stored in the state is not: compute texts in functions the view calls, or refresh them in onHost when "locale" changes. The widget's name and its menu labels are in the manifest, not in the locales.

overcrow-widget check warns when the view calls t("key") with a key that has no message, and refuses two files whose keys differ.

Drawing

For what the elements cannot show, a canvas element is a surface that the logic paints with a list of commands: paths, fills, strokes, text and images. draw(ref, commands) replaces the whole content of the canvas named ref.

TSdocs/content/examples/countdown/logic.ts
/** One dot per finished round, on the `dots` canvas. */
function paint(): void {
  const dots: DrawCommand[] = [];
  for (let index = 0; index < Math.min(state.rounds, MAX_DOTS); index += 1) {
    dots.push(["circle", 6 + index * 14, 6, 4], ["fill", "var(--color-accent)"]);
  }
  draw("dots", dots);
}

Coordinates are logical pixels from the top-left corner of the canvas; colours are literal (#rrggbb) or colour tokens. PlayerVox OverCrow checks every command and draws them itself: there is no pixel access and no shader. Draw again when what you show changes, and when host.theme changes if you use literal colours.

CommandArgumentsMeaning
moveTox: number -16384 to 16384; y: number -16384 to 16384Starts a subpath.
lineTox: number -16384 to 16384; y: number -16384 to 16384Straight segment.
quadTocx: number -16384 to 16384; cy: number -16384 to 16384; x: number -16384 to 16384; y: number -16384 to 16384Quadratic Bézier segment.
cubicToc1x: number -16384 to 16384; c1y: number -16384 to 16384; c2x: number -16384 to 16384; c2y: number -16384 to 16384; x: number -16384 to 16384; y: number -16384 to 16384Cubic Bézier segment.
arccx: number -16384 to 16384; cy: number -16384 to 16384; r: number -16384 to 16384; start: number -720 to 720; end: number -720 to 720Circular arc in degrees, clockwise.
rectx: number -16384 to 16384; y: number -16384 to 16384; w: number -16384 to 16384; h: number -16384 to 16384; radius: number -16384 to 16384Closed rounded-rectangle subpath.
circlecx: number -16384 to 16384; cy: number -16384 to 16384; r: number -16384 to 16384Closed circle subpath.
closenoneCloses the subpath.
fillcolor: <color>Fills and clears the current path.
strokecolor: <color>; width: number 0 to 256Strokes and clears the current path.
textx: number -16384 to 16384; y: number -16384 to 16384; text: text ≤ 1 KiB; size: number 6 to 96; color: <color>; align: start | center | end; family: ui | mono | display; weight: 400 | 500 | 600 | 700One line of text on its baseline.
imagesrc: image source; x: number -16384 to 16384; y: number -16384 to 16384; w: number -16384 to 16384; h: number -16384 to 16384Draws an image scaled into the rectangle.
savenonePushes transform, clip and alpha, ≤ 16.
restorenonePops the state; a restore without a save is an error.
clipx: number -16384 to 16384; y: number -16384 to 16384; w: number -16384 to 16384; h: number -16384 to 16384Intersects the clip rectangle with this one.
translatex: number -16384 to 16384; y: number -16384 to 16384Translates.
rotatedegrees: number -360 to 360Rotates.
scalex: number 0 to 8; y: number 0 to 8Scales.
alphavalue: number 0 to 1Multiplies opacity.

A chart, a gauge or a progress bar needs no canvas: the chart, gauge and progress elements draw them from values.

Logging

log.debug, log.info, log.warn and log.error take one text. They appear in the terminal of overcrow-widget dev and nowhere else: an installed widget's logs are dropped. Never log user data.

TSdocs/content/examples/countdown/logic.ts
  log.info(`round ${state.rounds} finished`);

What the logic cannot use

overcrow-widget check lints the logic against what the VM runs and names the SDK replacement:

CodeRule
logic.syntaxThe module parses as ES2023; TypeScript types are allowed.
logic.importIts only import is @overcrow/sdk: no other module, file or package, no export *.
logic.dynamic_import, logic.import_metaNo import() and no import.meta.
logic.eval, logic.function_constructorNo eval, no Function(…) or new Function(…).
logic.unavailable_globalEvery global it reads exists in the VM. Intl, setTimeout, console, fetch, WebAssembly, SharedArrayBuffer, performance, WeakRef, browser and Node.js globals do not.
logic.top_level_await, logic.top_level_thisNo top-level await or for await, no top-level this.
logic.syntax_versionNothing newer than ES2023: decorators, using, the regular expression v flag, import attributes.
logic.default_exportThe view calls named exports; export default is refused.
logic.missing_exportEvery function the view calls is exported.
logic.debuggerdebugger is a warning.

The lint helps you; it is not what protects the user. The sandbox, the VM's budgets and PlayerVox OverCrow's check of everything the widget sends do that.

What happens on a failure

  • An exception in a handler ends that turn; the widget keeps running, and overcrow-widget dev shows the fault.
  • A turn longer than its budget (50 ms), a heap over its ceiling or too many messages in a turn stop the widget. PlayerVox OverCrow shows a fixed error in its frame and restarts it after 1, 5 and 15 seconds, at most three times.
  • A rejected promise of a service call is an ordinary error of your code: catch it. See errors.

The numbers are in limits. The whole API of the SDK is in the SDK reference.