Authoring

Patterns

The four things nobody derives from the API surface, each learned the expensive way.

Everything on this page is invisible in a signature. Three of the four shipped as bugs first.

Subscribe for the set, animate from the read

world.on('cooldowns') tells you which cooldowns are running. It deliberately does not fire as a number counts down, because at frame rate that would be a handler call per ability per frame reporting something nobody acts on.

So the subscription decides which bars exist, and a frame loop decides how full each one is:

addons/cooldown-bars/main.js
// The cooldown set changes here; the numbers move in the frame loop below.
// Sampling the set every frame instead would be a Map walk per frame to notice
// nothing. Charges are the other way round, and the frame loop says why.
woc.world.on('cooldowns', syncBars);

/**
 * Redraw while anything is running, and stand down when nothing is.
 *
 * `syncBars` rather than `draw` when a charge pool is recharging: a charge coming
 * back while the pool still holds a use changes no cooldown id, so the
 * subscription cannot raise or drop those rows and only the loop can.
 */
function tick() {
  if (rechargingAbilities().length > 0) {
    syncBars();
  } else if (bars.size > 0) {
    draw();
  }
  woc.requestAnimationFrame(tick);
}
woc.requestAnimationFrame(tick);

An addon written the other way round has one of two bugs. Redraw only in the handler and the bar sits perfectly still for the whole cooldown. Drive the fill from the handler's own timer and it restarts every time the set changes.

The same shape applies to anything that animates: subscribe for the change, animate from the read.

A field can be declared, readable, and never sent

The game builds every entity with defaults and fills in whatever the snapshot carried. A field the server never sends is therefore present, of the right type, and holding that default for the entire session. Nothing throws. Nothing warns.

inCombat is the worked example. It is on the entity, it is a boolean, and it is never on the wire, so it reads false forever. The first version of the combat meter used it to decide a fight had ended, concluded that every fight had ended, and reset the total on every hit.

The published types mark which fields ride the self record and omit the ones that are never sent, but the types are a claim about another repository rather than a derivation from it. When a value matters, check it against a live session before you build on it.

You never write cleanup, but you do write woc.onDispose

Everything the API creates goes into a disposal bag: frames, subscriptions, key bindings, timers, sounds, tooltips. Disabling an addon drains it.

What is not covered is anything you made yourself. Enable and disable are fully hot, with no page reload, so a bare setInterval keeps running against DOM the loader has already removed.

const observer = new MutationObserver(update);
observer.observe(target, { childList: true });
woc.onDispose(() => observer.disconnect());

Use woc.setTimeout and woc.requestAnimationFrame rather than the page's, and register anything else with woc.onDispose.

Reuse the kit before styling your own

Give a button class="woc-btn" or a tab class="woc-tab" and it is drawn at your frame's density with the loader's hover and focus treatment, rather than an imitation of it.

Do not hand-roll a timer row either. woc.ui.bar is one: an icon, a name that truncates, a fill behind both, and a right-aligned figure in tabular figures so the digits do not shuffle as they count down.

addons/cooldown-bars/main.js
function createBar(abilityId) {
  const name = readable(abilityId);
  const bar = woc.ui.bar({
    label: name,
    icon: woc.ui.icon.ability(abilityId, playerClass()),
    className: 'woc-cd-bar',
  });
  bar.el.dataset.ability = abilityId;
  // The full name is one hover away, so truncating costs nothing.
  woc.ui.tooltip(bar.el, name);
  return bar;
}

The combat meter hand-rolled inline button styles first, and the two addons ended up drawing the same row two slightly different ways. That is what the kit exists to prevent.

A fifth, for anything reading combat events

An event's ability field is a display name, not an ability id. Every damage and heal2 emit fills it from ability.name; only castStart and spellfx carry ability.id. The declared type is string | null for both, so nothing tells you which you have.

This shipped a bug: the meter built an icon URL from the field and asked the game for Measured Shot.webp. An id is only safe to assume where the field is a map key, as in cooldowns and abilityCharges, or where the emit site says .id.

Healing has its own version of the same trap:

addons/combat-meter/main.js
// `heal2`, not `heal`: only the former carries a `sourceId`, so it is the only one
// a heal can be attributed from.
woc.net.onEvent('heal2', (event) => {
  const { player } = woc.world;
  if (player === null || event.sourceId !== player.id) {
    return;
  }
  // `cueOnly` events carry no healing and exist to drive a sound. The game's own
  // comment says a meter must ignore them by this FLAG rather than by amount, and
  // it is right: a genuine direct heal can legitimately land at 0 on a target at
  // full health, and inferring it from the amount would drop those too.
  if (event.cueOnly === true) {
    return;
  }
  noteActivity();
  if (event.amount > 0) {
    record('healed', labelOf(event), event);
  }
});