Skip to content

Extending with JavaScript

Ana's passage language covers most of what a game needs: state, conditionals, loops, the whole macro library. But some things are genuinely easier (or only possible) in JavaScript: shuffling a deck, scoring a blackjack hand, laying out a custom widget, or drawing an animated canvas. For those, the engine gives you three escape hatches:

MacroWhat it does
(script:)Runs a JavaScript function body, bridged to your game state.
(html:)Injects raw HTML into the current zone.
(css:)Injects a <style> tag into the page (once).

Reach for these last. If an engine macro or an in-language (macro:) can do the job, use that; it's safer, reads better, and works in any theme. JavaScript is for the things the language can't express.

Two styles, pick one

There are two ways to run JavaScript, and both are open to game authors and mods. The only question is whether you need it once or everywhere:

  • One-off, inline: drop JavaScript straight into a passage with (script:) (plus (html:) / (css:) for markup). Perfect for a quick computation or a single widget.
  • Reusable, named macro: write a .js file that registers a macro with engine.macros.register("deck-shuffle", …), and (deck-shuffle:) becomes available to every passage, like a built-in. Authors put these files in scripts/; mods ship a macros.js.

The smallest possible version of each:

ana
// Inline — runs right here, returns values as _temp vars
(script: "return { roll: Math.floor(Math.random() * 6) + 1 };")
You rolled _roll.
js
// Reusable — in scripts/dice.js (or a mod's macros.js)
export default function register(engine) {
  engine.macros.register('d6', () => Math.floor(Math.random() * 6) + 1);
}
ana
// …then in any passage:
You rolled (d6:).

Both styles share the same mental model, so read the (script:) sections first; the ctx ideas carry straight into the reusable form.


The (script:) macro

(script:) takes a string containing a JavaScript function body and runs it immediately, at the point the passage reaches it. The string is handed to the engine, wrapped as function (ctx) { … }, and called with a ctx bridge object.

ana
(script: "
  const n = Math.floor(Math.random() * 6) + 1;
  return { roll: n };
")
You rolled _roll on the die.

Two things are happening:

  1. The body runs as soon as the passage executes that line.
  2. If the body returns a plain object, each of its keys becomes a _temp variable in the current passage ({ roll: 4 }_roll is 4). That's how you get values back out into your prose.

Writing the body: the quoting rule

The script body is a double-quoted string in your .ana file, so you cannot use unescaped double quotes inside it. Use single quotes for strings and backticks for templates:

ana
// ✗ Breaks — the double quote ends the string early
(script: "const name = "Sam"; return { greeting: name };")

// ✓ Single quotes and backticks are safe
(script: "
  const name = 'Sam';
  return { greeting: `Hello, ${name}!` };
")

Multi-line bodies are fine; write them across as many lines as you like between the quotes.

The ctx bridge

ctx is your one connection to the game. Inside a (script:) it lets you read and write declared state and adjust a few engine settings, but nothing that would let a buggy script corrupt the engine's internals:

  • ctx.get("ns.key") / ctx.set("ns.key", value): read/write a declared $ variable (no $ sigil: "player.gold"). Writes are reactive and respect type/bounds.
  • ctx.temp("name"): read a _temp (no _ sigil: "roll").
  • ctx.rebind, ctx.setClockFormat, ctx.setDateFormat: settings helpers (see worked example 3).

Plus the return value → temps channel described above. For the exact method list and signatures, see the (script:) reference. There is intentionally no way to declare new variables, tear down zones, or navigate from a script; those belong to the passage language.

Reading and writing state: the rules

A few things will save you debugging time:

Variables must already be declared. ctx.get/ctx.set work on variables you declared in GameInit with (declare:). Reading or writing an undeclared variable throws. To persist new state from a script, declare it first:

ana
// GameInit
(declare: $world.deck, [])
(declare: $world.hand, [])

Writes respect type and bounds. If you declared $player.health as a bounded number, ctx.set('player.health', 999) clamps to its max, and setting it to a string throws. This is the same safety the (set:) macro gives you.

Mutating an array in place does not update watchers. ctx.get hands you the stored array by reference. If you push to it, the underlying signal still points at the same array, so nothing re-renders. Always build a new value and set it back:

ana
(script: "
  const hand = ctx.get('world.hand').slice();   // copy
  hand.push('the ace of spades');
  ctx.set('world.hand', hand);                   // set back → watchers fire
")

Errors are caught and labelled. If your script throws, the passage reports (script:) runtime error: <message> instead of crashing silently, which is handy while you're iterating.


Worked example 1: computing a card deck

This is the bread-and-butter case: an algorithm the macro language can't express cleanly. Everything stays in $ state; no DOM involved.

ana
// GameInit
(declare: $world.deck, [])
(declare: $world.hand, [])

Build and shuffle a fresh deck (Fisher–Yates):

ana
(script: "
  const suits = ['♠', '♥', '♦', '♣'];
  const ranks = ['A','2','3','4','5','6','7','8','9','10','J','Q','K'];
  const deck = [];
  for (const s of suits) for (const r of ranks) deck.push(r + s);
  for (let i = deck.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }
  ctx.set('world.deck', deck);
  ctx.set('world.hand', []);
")

Draw a card (and report what was drawn through a temp):

ana
(script: "
  const deck = ctx.get('world.deck').slice();
  const hand = ctx.get('world.hand').slice();
  const card = deck.pop();
  hand.push(card);
  ctx.set('world.deck', deck);
  ctx.set('world.hand', hand);
  return { drawn: card, left: deck.length };
")
You drew the _drawn. (_left cards left in the deck.)

Score the hand the way blackjack does, with aces flexing between 11 and 1:

ana
(script: "
  const hand = ctx.get('world.hand');
  let total = 0, aces = 0;
  for (const card of hand) {
    const rank = card.slice(0, -1);            // strip the suit glyph
    if (rank === 'A') { aces++; total += 11; }
    else if (rank === 'J' || rank === 'Q' || rank === 'K' || rank === '10') total += 10;
    else total += Number(rank);
  }
  while (total > 21 && aces > 0) { total -= 10; aces--; }
  return { total: total, bust: total > 21 };
")
Hand total: _total.
(if: _bust)[(text-color: "red")[Bust!]]

Notice the division of labor: JavaScript does the computation, then writes results back to $ state and _temp vars. Your passage prose and macros handle the presentation. Keep that split and your game stays readable.


(html:): custom markup

(html:) drops raw HTML into the current zone. The argument is evaluated, so it can be a string literal or a value you built in a script:

ana
(html: "<div class='banner'>Welcome to the Rusty Tankard</div>")

To make it dynamic, build the markup string in a (script:), return it as a temp, and pass that temp to (html:):

ana
(script: "
  const gold = ctx.get('player.gold');
  const danger = gold < 10;
  return {
    purse: `<div class='purse ${danger ? 'low' : ''}'>
              <span class='label'>Purse</span>
              <span class='amount'>${gold}g</span>
            </div>`
  };
")
(html: _purse)

Heads up: content from (html:) lands in a content zone, which the engine rebuilds on every navigation. It's not persistent, so re-emit it in each passage that needs it (or use a persistent zone / hook if it should survive navigation).

See the (html:) reference for the exact signature.


(css:): styling your markup

(css:) injects a <style> tag into the page. It's idempotent (calling it again with the same string does nothing), so it's safe to put right next to the markup it styles, in any passage:

ana
(css: "
  .purse { display: inline-flex; gap: .5rem; align-items: baseline;
           border: 1px solid var(--ana-border); border-radius: 6px;
           padding: .4rem .8rem; }
  .purse .amount { font-weight: 700; color: var(--ana-accent); }
  .purse.low .amount { color: var(--ana-danger, #e06c75); }
")

Use the engine's theme variables (var(--ana-accent), var(--ana-border), …) rather than hard-coded colours, so your widget follows the player's chosen theme.


Worked example 2: an animated canvas

This is the most advanced inline case, and it exposes the two timing rules you have to know.

The timing rule: (script:) runs while the passage is being built, before the engine has flushed anything to the screen. So a script cannot find an element that an (html:) on the same passage is about to create, because that element isn't in the DOM yet. The fix is to defer your DOM work with setTimeout(…, 0), which runs after the passage has been painted.

The cleanup rule: when the player navigates away, the engine rebuilds the zone and your element disappears, but a requestAnimationFrame loop you started will keep running forever unless you stop it. Guard the loop so it ends the moment its element leaves the page.

Putting both together, here's a little animated waveform:

ana
(html: "<canvas id='wave' width='220' height='64'></canvas>")
(script: "
  // Defer until after the canvas is actually on the page.
  setTimeout(() => {
    const canvas = document.getElementById('wave');
    if (!canvas) return;
    const g = canvas.getContext('2d');
    let t = 0;

    function frame() {
      // Self-terminate once navigation has removed the canvas — no leak.
      if (!document.body.contains(canvas)) return;
      t += 0.05;
      g.clearRect(0, 0, 220, 64);
      g.beginPath();
      for (let x = 0; x < 220; x++) {
        const y = 32 + Math.sin(x * 0.08 + t) * 22;
        x === 0 ? g.moveTo(x, y) : g.lineTo(x, y);
      }
      g.strokeStyle = getComputedStyle(canvas).getPropertyValue('--ana-accent') || '#6cf';
      g.lineWidth = 2;
      g.stroke();
      requestAnimationFrame(frame);
    }
    frame();
  }, 0);
")

The same shape works for a meter that fills smoothly, a particle burst on a level-up, or a small rhythm/Tetris-style minigame: emit a container with (html:), defer into it with setTimeout, and guard the animation loop with document.body.contains(...).

Those two rough edges, the setTimeout defer and the contains guard, are exactly what the reusable form removes: ctx.mount() hands you an element that's already on the page and managed, and ctx.onCleanup() cancels your animation on navigation. If you'll use a widget more than once, write it as a macro instead. The same canvas appears below in that cleaner form.


Worked example 3: driving game settings

Not everything is a widget. Scripts are also a clean way to wire up a settings screen, using the bridge's keybind and HUD-format helpers:

ana
// A "use 24-hour clock" toggle
(link: "Use 24-hour clock")[(script: "ctx.setClockFormat('24h');")]

// Let the player move the inventory shortcut to the B key
(link: "Bind inventory to B")[(script: "ctx.rebind('inventory', 'b');")]

For anything involving game values, prefer reading and writing through ctx.get/ctx.set so the engine's bounds and reactivity stay intact.


Reusable JS macros (scripts/ and macros.js)

(script:) is perfect for one-offs, but you don't want to paste the same deck shuffle into twenty passages. Register a named macro backed by JavaScript once, and every passage can call it like any built-in.

The file, and where it goes

The only difference between an author and a mod here is where the file lives:

  • Game author: drop any .js file into the scripts/ folder at the project root. Every .js in scripts/ is loaded at boot.
  • Mod: ship a macros.js inside your mod's folder, next to its mod.meta.
scripts/
  cards.js               ← author: loaded for the base game
mods/
  card-game/
    mod.meta
    macros.js            ← mod: loaded with the mod
    passages/CardTable.ana

Either file default-exports a function that receives the engine API and registers macros:

js
// scripts/cards.js  (or  mods/card-game/macros.js)
export default function register(engine) {
  engine.macros.register('deck-shuffle', (args, ctx) => {
    const deck = Array.isArray(args[0]) ? args[0].slice() : [];
    for (let i = deck.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [deck[i], deck[j]] = [deck[j], deck[i]];
    }
    return deck;
  });
}

Now any passage can use it:

ana
(set: $world.deck to (deck-shuffle: $world.deck))

When registration happens

Every author scripts/*.js and mod macros.js is loaded once at boot, after all built-in macros exist (so collisions are caught) and before GameInit runs (so your macros are ready). Author scripts load first (the base game), then mods in dependency order (a mod that requires another registers after it), so a mod can extend or override an author macro (last to load wins). Unlike in-language (macro:), JS macros are not re-registered per game; they persist across new-game and load, like built-ins. (Editing a .js file during development needs a full page reload, not just a hot-update.)

The handler: (args, ctx)

Each macro is a function (args, ctx) => …:

  • args is an array of the call's arguments, already evaluated to plain JS values. (deck-shuffle: $world.deck) calls your function with args[0] set to the actual deck array, so you don't deal with raw syntax nodes. Validate them yourself (variadic by default).
  • Return a value to make it a value macro, usable in (set:), (if:), anywhere an expression goes. Return nothing and use the ctx content methods to make it a content macro. (You can do both.)

A reusable macro's ctx is richer than (script:)'s: alongside get/set/temp it can setTemp, emit content (print/html/feedback/notify), and mount managed DOM (mount/onCleanup). The full table with signatures lives in the reusable-JS-macro reference. The examples below cover the common shapes.

Example: a value macro

A die roll any game can use in a condition:

js
engine.macros.register('mod-dice', (args) => {
  const sides = Number(args[0]) || 6;
  return Math.floor(Math.random() * sides) + 1;
});
ana
(if: (mod-dice: 20) >= 15)[A critical hit!]

Example: a content macro that emits HTML

js
engine.macros.register('gold-badge', (args, ctx) => {
  const gold = ctx.get('player.gold');
  ctx.html(`<span class="gold-badge">${gold}g</span>`);
});
ana
You have (gold-badge:) to your name.

Example: the canvas, with managed cleanup

This is where macros.js shines over inline (script:). It's the same animated canvas, but ctx.mount() returns an element the engine attaches for you, and ctx.onCleanup() ties teardown to navigation, so the setTimeout defer and the document.body.contains guard both disappear:

js
engine.macros.register('sparkline', (args, ctx) => {
  const el = ctx.mount();
  const canvas = document.createElement('canvas');
  canvas.width = 220; canvas.height = 48;
  el.appendChild(canvas);
  const g = canvas.getContext('2d');

  let t = 0, raf = 0;
  function frame() {
    t += 0.06;
    g.clearRect(0, 0, 220, 48);
    g.beginPath();
    for (let x = 0; x < 220; x++) {
      const y = 24 + Math.sin(x * 0.09 + t) * 16;
      x === 0 ? g.moveTo(x, y) : g.lineTo(x, y);
    }
    g.strokeStyle = getComputedStyle(canvas).getPropertyValue('--ana-accent').trim() || '#6cf';
    g.lineWidth = 2;
    g.stroke();
    raf = requestAnimationFrame(frame);
  }
  raf = requestAnimationFrame(frame);
  ctx.onCleanup(() => cancelAnimationFrame(raf));   // stops on navigation
});
ana
The signal pulses: (sparkline:)

Runnable versions live in the engine repo: mods/example-mod/macros.js (mod-dice, mod-shuffle, mod-sparkline) and the author-side scripts/game-macros.js (author-greet, author-bar), both exercised by the in-game Mod Tests screen.

Naming, collisions & errors

  • Names follow the usual rules: letters, digits, hyphens, underscores; no dots. Prefixing with your mod's name (carddeck-shuffle) avoids clashes.
  • You cannot override a built-in macro; the registration is refused and logged.
  • If two mods (or a game and a mod) register the same name, the last to load wins, with a console warning. Load order is your requires order.
  • If a macro throws at call time, the engine shows an inline error and the rest of the passage still renders, so one buggy macro won't blank the screen.

Safety and sandboxing

(script:) and JS macros run real JavaScript with full page privileges: they can touch the DOM, window, timers, and the network. That power is the point, but it means:

  • Only ship or load mods you trust. A script can do anything a web page can.
  • The ctx bridge is intentionally narrow. It cannot reach the engine's render queue, zone teardown, or navigation, which keeps scripts unable to destabilise the core even by accident.
  • Scripts run synchronously, so a value a script sets is visible to macros later in the same passage. Order your lines accordingly.

Quick reference

Inline, in a passage ((script:)):

  • Compute / algorithms(script:), read with ctx.get, write with ctx.set, return an object for _temp results.
  • Static markup(html: "…") + (css: "…").
  • Dynamic markup → build the string in (script:), return it as a temp, pass to (html: _temp).
  • Canvas / animation(html:) a container, then (script:) with setTimeout(…, 0) to draw, and guard requestAnimationFrame with document.body.contains(el).
  • Quoting → single quotes and backticks inside the body; never an unescaped ".

Reusable, in a .js file (scripts/ for the game, macros.js for a mod):

  • Register → default-export function (engine) { engine.macros.register("name", (args, ctx) => …) }.
  • Value macroreturn a value; usable in (set:) / (if:). Content macro → use ctx.print / ctx.html / ctx.mount.
  • Animationctx.mount() for the element, ctx.onCleanup(fn) to stop loops on navigation, with no setTimeout/contains dance.
  • args arrive already evaluated; collisions can't override built-ins, last-loaded wins otherwise.

State rules (both): variables must be declared first; writes respect type/bounds; set arrays back, don't mutate in place.

See also: (script:) / (html:) / (css:) and the reusable-JS-macro API in the reference, in-language (macro:) for reusable composition without JavaScript, Boot, Saves & Mods for the mod layout, and Theming & Modals for the CSS variables your widgets should use.