Examples index
971 documented examples across 33 modules, and the 29 runnable examples that use them. Each snippet is the start of the @example in the symbol's doc comment, compiled by the test suite against the public import paths.
3d
buildHeightIndex
function
looks up a cell's height by its integer grid coordinate in O(1) rather than scanning cells on every query
import { buildHeightIndex, heightAt } from '@datamoc/mwg/3d';
const heights = buildHeightIndex([
{ x: 0, y: 0 },
{ x: 1, y: 0, height: 2 },
]);
console.log(heightAt(heights, 'square', 1.2, 0.1)); // 2
cellAt
function
The integer grid cell nearest world position (x, z) - the inverse of gridPoint3D. Nearest-cell rounding, not an exact point-in-hexagon test for the hex shape: consistent with rpg.Collision's own choice of simple axis sweeps over a ...
import { cellAt } from '@datamoc/mwg/3d';
console.log(cellAt('square', 3.4, 1.6, 2)); // { x: 2, y: 1 }
gridPoint3D
function
Converts the framework's integer square or odd-q hex coordinate to 3D world space.
import { gridPoint3D } from '@datamoc/mwg/3d';
//cell (3, 2), one level up, on a grid of 2-unit tiles with 0.5-unit levels
const point = gridPoint3D('square', 3, 2, 2, 1, 0.5);
console.log(point); // { x: 6, y: 0.5, z: 4 }
heightAt
function
a cell's own height (grid-height units, before heightStep scaling), or null for a world position with no cell at all - a hole in the grid, blocking just like a wall would
import { buildHeightIndex, heightAt } from '@datamoc/mwg/3d';
const heights = buildHeightIndex([{ x: 0, y: 0, height: 1 }]);
console.log(heightAt(heights, 'square', 0, 0), heightAt(heights, 'square', 5, 5)); // 1 null
parseVox
function
Reads one MagicaVoxel VOX model, including an optional RGBA palette chunk.
import { parseVox } from '@datamoc/mwg/3d';
declare const voxBytes: ArrayBuffer;
const model = parseVox(voxBytes);
console.log(model.size, model.voxels.length);
resolveCapsuleAgainstGrid
function
Moves a capsule's footprint from from toward to in the XZ plane, stopping at the last sampled position still within maxStepUp of the cell it started the move standing on, rather than passing straight through a column's side the way ...
import { buildHeightIndex, resolveCapsuleAgainstGrid } from '@datamoc/mwg/3d';
const heights = buildHeightIndex([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0, height: 3 },
]);
//a step of 3 levels is more than the capsule can climb, so it stops on the cell before the column
const moved = resolveCapsuleAgainstGrid({ x: 0, z: 0 }, { x: 2, z: 0 }, { shape: 'square', heights, maxStepUp: 1 });
console.log(moved.blocked, moved.x < 1.5); // true true
3d/models
loadModelContainer3D
function
Loads a model once per resolved source and caches the in-flight/loaded AssetContainer, so a second call for the same source returns the same container instead of importing and parsing it again over the network. A container is not itself ...
import { loadModelContainer3D, isModelContainerLoaded, releaseModelContainer } from '@datamoc/mwg/3d/models';
declare const scene: import('@babylonjs/core/scene.js').Scene;
const container = await loadModelContainer3D('models/tree.glb', scene);
const treeA = container.instantiateModelsToScene();
const treeB = container.instantiateModelsToScene(); // a second, independent copy - no re-fetch
console.log(isModelContainerLoaded('models/tree.glb')); // true
releaseModelContainer('models/tree.glb'); // frees the container once every copy is placed
actors
AdvancementTierKind
type
Tiered specialization: level-gated tiers that grant points, offer a mutually-exclusive branch choice, or offer a capstone choice.
import { Advancement, type AdvancementTrack } from '@datamoc/mwg/actors';
const track: AdvancementTrack = {
tiers: [
{ threshold: 5, kind: 'points', points: 2 },
{ threshold: 10, kind: 'branch', options: [{ id: 'ranger' }, { id: 'mage' }] },
],
};
const advancement = new Advancement(track);
...
AffixTrigger
type
Named item affixes: enchantments, glyphs, augments, curses - whatever a game's items can carry beyond their upgrade level.
import { rollAffix, applyAffix, affixOf, removeAffix, copyAffix, matchesContext, type AffixTable } from '@datamoc/mwg/actors';
const table: AffixTable = { entries: [{ id: 'flaming', trigger: 'strike', weight: 1, kinds: ['melee'] }] };
const sword = { id: 'sword', quantity: 1 };
const betterSword = { id: 'sword', quantity: 1, instanceId: 'b' };
const affix = rollAffix(table);
if (affix) applyAffix(sword, affix);
console.log(affixOf(sword)); // 'flaming'
...
AuraField
class
Continuously reapplies auras as who is adjacent to whom changes, unlike battle.BattleHooks (triggered once) or actors.StatusEffect (expires on a timer) - neither re-checks "is a qualifying unit still adjacent" on its own, which is ...
import { AuraField, StatBlock, type AuraParticipant } from '@datamoc/mwg/actors';
const field = new AuraField();
const captain: AuraParticipant = {
stats: new StatBlock({ base: {} }),
aura: { name: 'rally', modifiers: [{ stat: 'attack', op: 'add', value: 2 }] },
};
const soldier: AuraParticipant = { stats: new StatBlock({ base: { attack: 5 } }) };
// call every time positions change, with whatever "adjacent" means on this grid
...
BarrierLayer
interface
A shield/barrier pool absorbed before HP, decaying on its own each tick unlike a plain StatBlock stat, which only ever changes when something explicitly touches it. Several layers can be held at once - a fresh cast over an older one, a ...
import { Barrier } from '@datamoc/mwg/actors';
const barrier = new Barrier();
barrier.add(20); // a shield spell
barrier.add(10, 2); // a ward on top, decaying 2 per turn on its own
const absorbed = barrier.absorb(15); // drains the ward first, then spills into the shield
barrier.advance(1); // one turn of decay
console.log(barrier.total);
ChargesOptions
interface
A resource that refills on its own as turns pass - a wand's limited charges regenerating one every few turns - distinct from a straight spendable pool (mana spent per cast, restored only by an explicit event, which a StatBlock stat ...
import { Charges } from '@datamoc/mwg/actors';
const wand = new Charges({ max: 3, regenRate: 10 }); // one charge every 10 turns
wand.spend(1);
wand.advance(10); // 10 turns pass; one charge regenerates
console.log(wand.current); // back to max
wand.refund(1); // a kill or crit grants a charge directly, outside the normal regen path
EntityTemplateRow
interface
One row of a hero or monster "file" - a core.parseCSV result, typically - turned into a fully wired entity: a StatBlock of base stats, an optional Progression against a named growth curve, an optional starting item carrying a named ...
import { parseCSV } from '@datamoc/mwg/core';
import { buildEntities, type EntityTemplateRow } from '@datamoc/mwg/actors';
const csv = `id,attack,speed,growth,startingAffix,startingItem,lowHpReaction
fireling,10,8,steep,blazing,ember_charm,0.25`;
const rows = parseCSV<EntityTemplateRow>(csv, {
columns: { attack: 'number', speed: 'number', lowHpReaction: 'number' },
});
...
EquipmentSlots
class
Equipment slots, defined per game rather than fixed by the framework - 'leftHand' and 'rightHand', or 'weapon' / 'armor' / 'amulet', or a dozen Diablo-style slots. Whatever an item's modifiers are, equipping it applies them to ...
import { EquipmentSlots, StatBlock } from '@datamoc/mwg/actors';
const stats = new StatBlock({ base: { attack: 3 } });
const equipment = new EquipmentSlots<'weapon' | 'armor', { modifiers?: import('@datamoc/mwg/actors').Modifier[] }>(
['weapon', 'armor'],
stats
);
const sword = { modifiers: [{ stat: 'attack', op: 'add' as const, value: 5 }] };
equipment.equip('weapon', sword);
...
InventoryItem
interface
Items a character carries: stacking, weight, and containers within containers.
import { Inventory } from '@datamoc/mwg/actors';
const bag = new Inventory({ capacity: 50 });
bag.add({ id: 'potion', quantity: 3, stackable: true, weight: 0.5 });
bag.add({ id: 'potion', quantity: 2, stackable: true, weight: 0.5 }); // merges into one stack of 5
const potion = bag.find('potion');
console.log(potion?.quantity, bag.totalWeight);
LevelScale
interface
Level-scaled item modifiers: the shape passive gear whose bonus grows with its own upgrade level shares, without any one game's scaling curve.
import { scaledModifiers, type LevelScale } from '@datamoc/mwg/actors';
const ringScales: LevelScale[] = [{ stat: 'evasion', op: 'add', base: 1, perLevel: 1 }];
const modifiers = scaledModifiers(3, ringScales); // [{ stat: 'evasion', op: 'add', value: 4 }]
Price
interface
A buy/sell transaction between two Inventorys, paid for from a StatBlock stat acting as currency - the same role craft() fills for a recipe: check everything first, touch nothing until every check passes, and if any later step fails ...
import { buy, sell, StatBlock, Inventory, type ShopOptions } from '@datamoc/mwg/actors';
const wallet = new StatBlock({ base: { gold: 100 } });
const shopStock = new Inventory();
shopStock.add({ id: 'potion', quantity: 10, stackable: true });
const bag = new Inventory();
const options: ShopOptions = { currency: 'gold', prices: new Map([['potion', { buy: 15, sell: 5 }]]) };
buy(wallet, shopStock, bag, 'potion', 2, options); // 30 gold spent, 2 potions in bag
...
ResourceCost
interface
One spendable cost: amount of the stat pool, the way a spell costs mana.
import { canAfford, spend, refund, convertToCharges, StatBlock, Charges } from '@datamoc/mwg/actors';
const stats = new StatBlock({ base: { mana: 30 } });
const cost = { stat: 'mana', amount: 10 };
if (canAfford(stats, cost)) spend(stats, cost);
refund(stats, cost, 0.5); // half the cost back, e.g. on a missed cast
const battery = new Charges({ max: 5, regenRate: 10 });
convertToCharges(stats, { stat: 'mana', amount: 20 }, battery, 5); // 20 mana -> 4 charges
SkillPoints
class
The seam StatBlock and Progression never had: a levelling curve already tells a game how many levels it just gained, and a stat block already holds any named stat a game invents - lockpicking exactly as well as strength - but ...
import { SkillPoints, StatBlock } from '@datamoc/mwg/actors';
const stats = new StatBlock({ base: { lockpicking: 0 } });
const points = new SkillPoints(stats, { cap: () => 10, cost: (_stat, rank) => rank });
points.grant(3); // e.g. after Progression.addExperience returned levelsGained
if (points.canSpend('lockpicking')) points.spend('lockpicking');
console.log(stats.base('lockpicking'), points.points);
SupportLedger
class
Stores persistent progress for a relationship between a specific pair of units. Pair order is ignored, so add('alice', 'bob', 1) and the reverse address one bond.
import { SupportLedger, type SupportLevel } from '@datamoc/mwg/actors';
const levels: SupportLevel[] = [{ id: 'C', threshold: 10 }, { id: 'B', threshold: 30 }];
const support = new SupportLedger(levels);
support.add('hero', 'ally', 12); // proximity, a shared battle, whatever a game counts
console.log(support.level('ally', 'hero')?.id); // 'C' - pair order does not matter
applyItemStatusEffect
function
A temporary status on an item itself - a weapon coated in poison for a fixed number of turns, a shield reinforced until it wears off, a ring blessed only "for this floor" - the item-targeted counterpart to applyStatusEffect, which only ...
import { applyItemStatusEffect } from '@datamoc/mwg/actors';
import { TurnClock } from '@datamoc/mwg/world';
const sword = { id: 'sword', quantity: 1, blessed: false };
const clock = new TurnClock();
const blessing = applyItemStatusEffect(sword, clock, { fields: { blessed: true }, duration: 3 });
clock.advance(3); // blessed reverts to false automatically
blessing.cancel(); // or end it early
applyStatusEffect
function
A temporary buff or debuff: applies modifiers to stats and registers their expiry with clock, so a game never has to remember to remove what it added. The seam TurnClock and StatBlock never had on their own - TurnClock ticks ...
import { applyStatusEffect, StatBlock } from '@datamoc/mwg/actors';
import { TurnClock } from '@datamoc/mwg/world';
const stats = new StatBlock({ base: { hp: 20, attack: 5 } });
const clock = new TurnClock();
const poison = applyStatusEffect(stats, clock, {
modifiers: [{ stat: 'attack', op: 'add', value: -2 }],
duration: 3,
tick: () => stats.setBase('hp', stats.base('hp') - 2),
...
assignAppearances
function
Deals each kind a distinct label from a shuffled copy of the pool - pure, so a game that wants the shuffle scoped (one seed per run, say) wraps it in Random.withSeed.
import { assignAppearances, Appearances } from '@datamoc/mwg/actors';
const potions = assignAppearances({ kinds: ['healing', 'poison'], labels: ['red', 'blue'] });
console.log(potions.get('healing')); // 'red' or 'blue', fixed for this shuffle
// Appearances wraps this per category, drawing lazily on first ask and caching for the run
const appearances = new Appearances({ potions: { kinds: ['healing', 'poison'], labels: ['red', 'blue'] } });
const label = appearances.appearanceOf('potions', 'healing');
assignTraits
function
Draws count distinct traits at random from pool and applies their modifiers to stats permanently - no clock, no trigger, nothing removes them automatically. Each trait gets its own source symbol so a game that genuinely needs to ...
import { assignTraits, StatBlock, type TraitDef } from '@datamoc/mwg/actors';
const stats = new StatBlock({ base: { strength: 10, speed: 10 } });
const pool: TraitDef[] = [
{ name: 'strong', modifiers: [{ stat: 'strength', op: 'add', value: 3 }] },
{ name: 'fast', modifiers: [{ stat: 'speed', op: 'add', value: 3 }] },
];
const assigned = assignTraits(stats, pool, 1); // one random trait, applied permanently
console.log(assigned[0].trait.name);
buildEntity
function
import { buildEntity } from '@datamoc/mwg/actors';
const goblin = buildEntity({ id: 'goblin', attack: 4, speed: 3 }, {});
console.log(goblin.stats.get('attack')); // 4
composeModifiers
function
Resolves one stat's modifiers into a single number, in the framework's canonical add, multiply, then set composition.
import { composeModifiers } from '@datamoc/mwg/actors';
const attack = composeModifiers(10, [{ stat: 'attack', op: 'add', value: 2 }]);
console.log(attack); // 12
craft
function
Resolves a recipe against an inventory: every ingredient checked, consumed, and the result added, all in one call - the same small-focused-function shape skillCheck already is, not a crafting subsystem of its own. Inventory only ever ...
import { craft, Inventory, type Recipe } from '@datamoc/mwg/actors';
const bag = new Inventory();
bag.add({ id: 'sage', quantity: 2, stackable: true, category: 'herb' });
bag.add({ id: 'ember', quantity: 1, stackable: true, category: 'runestone' });
const recipe: Recipe = {
ingredients: [
{ category: 'herb', quantity: 2 },
{ category: 'runestone', quantity: 1 },
...
powerCurve
function
A curve reaching maxLevel at roughly base * (level - 1) ^ power total experience.
import { powerCurve, Progression } from '@datamoc/mwg/actors';
const curve = powerCurve(100, 1.5, 50); // level 50 caps out around 100 * 49^1.5 experience
const progression = new Progression(curve);
const levelsGained = progression.addExperience(250);
console.log(progression.level, progression.experienceToNext);
rollLoot
function
import { rollLoot, type LootTable } from '@datamoc/mwg/actors';
const ratLoot: LootTable = {
chance: 0.5,
entries: [{ id: 'gold', weight: 3, quantity: 5 }, { id: 'cheese', weight: 1 }],
};
const drop = rollLoot(ratLoot); // { id: 'gold', quantity: 5 }, or null, most of the time
skillCheck
function
A skill checked against a difficulty.
import { skillCheck } from '@datamoc/mwg/actors';
const success = skillCheck(5, 15); // proficiency 5, difficulty 15, default d20 roll
const withCustomDice = skillCheck(5, 15, () => 12); // an injected, non-random roll
toEntitySaveState
function
A BuiltEntity's mutable state, in the same "definitions supplied fresh on load" shape StatBlock.toJSON/Progression.toJSON already draw: base stat values, level and experience, and the carried item, never the row or catalog that ...
import { buildEntity, toEntitySaveState, fromEntitySaveState, type EntityTemplateCatalog } from '@datamoc/mwg/actors';
const catalog: EntityTemplateCatalog = {
growthCurves: { steep: { maxLevel: 20, experienceFor: (level) => level * level * 10 } },
};
const row = { id: 'fireling', growth: 'steep', attack: 10 };
const fireling = buildEntity(row, catalog);
fireling.stats.setBase('attack', 12);
fireling.progression?.addExperience(500);
...
ai
Aspects
class
The named tuning knobs of a heuristic AI - Wesnoth's "aspects" - read by whatever stage is scoring candidates. mwg ships no values, because the numbers are game content: a side sets aggression, caution, village_value, keep_away ...
import { Aspects } from '@datamoc/mwg/ai';
const aspects = new Aspects({ aggression: 2, keep_away: 3 });
console.log(aspects.number('aggression')); // 2
console.log(aspects.number('caution')); // 0 - the fallback
Difficulty
class
Named difficulty levels, each a set of aspect overrides - Wesnoth's native difficulty ladder, which is really "the same AI with different numbers". A level is applied on top of a base aspect set, so a game writes its normal AI once and a ...
import { Aspects, Difficulty } from '@datamoc/mwg/ai';
const difficulty = new Difficulty(
[{ id: 'hard', aspects: { aggression: 2, keep_away: 3 } }],
{ aggression: 1 },
);
console.log(difficulty.aspectsFor('hard').number('aggression')); // 2
Goals
class
Per-unit goals - "hold this village", "attack that leader" - kept as a small registry for a stage's weigh to read. mwg does not decide what a goal is worth, the same way it ships no damage formula: the registry answers *what* a unit ...
import { Goals } from '@datamoc/mwg/ai';
const goals = new Goals();
goals.set({ unit: 'hero', kind: 'attack', target: 'rat', priority: 3 });
console.log(goals.get('hero')?.kind); // 'attack'
HeuristicAI
class
The candidate-action pipeline at the heart of a Wesnoth-style AI: stages run in order, the first one whose when passes scores every candidate with its weigh, and the highest score wins (the earlier candidate on a tie, so a decision is ...
import { Aspects, HeuristicAI } from '@datamoc/mwg/ai';
const ai = new HeuristicAI<{ kind: string }>({ stages: [{ id: 'best' }] });
const decision = ai.decide(
[
{ id: 'wait', action: { kind: 'wait' }, factors: { caution: 2 } },
{ id: 'charge', action: { kind: 'attack' }, factors: { aggression: 3 } },
],
{ aspects: new Aspects({ aggression: 1, caution: 1 }) },
);
...
JavaScriptAI
class
Runs game-owned JavaScript behaviours without knowing anything about a renderer or game rules.
import { JavaScriptAI, AICancelledError, AIBudgetExceededError } from '@datamoc/mwg/ai';
const ai = new JavaScriptAI({ seed: 7 });
ai.register({ id: 'guard', behaviors: [{ id: 'wait', decide: () => ({ type: 'wait' }) }] });
const decision = ai.decide('guard', { perception: { visible: true } });
console.log(decision.action?.type); // 'wait'
void AICancelledError;
void AIBudgetExceededError;
NeuralPolicy
class
A small shared dense policy, usable inside existing JavaScriptAI behaviours. Model weights are copied on construction. Supply a seeded random callback to sample softmax scores; omit it for the first highest-scoring legal action. All-masked ...
import { NeuralPolicy, JavaScriptAI } from '@datamoc/mwg/ai';
const policy = new NeuralPolicy({ version: 1, observationVersion: 'v1', layers: [
{ inputSize: 1, outputSize: 2, weights: [-1, 1], biases: [0, 0] },
] });
console.log(policy.selectAction({ input: [2] })); // 1
const ai = new JavaScriptAI({ seed: 7 });
ai.register({ id: 'npc', behaviors: [policy.behavior('neural',
[{ type: 'left' }, { type: 'right' }], () => ({ input: [2] }))] });
console.log(ai.decide('npc', { perception: null }).action?.type); // right
const scores = await policy.predictBatchAsync([[2], [-2]]);
RecruitmentPattern
class
The order a side recruits in - Wesnoth's recruitment_pattern, e.g. scout, fighter, archer - cycled by turn. A side with nothing left to recruit falls back to its fallback, and a side with neither recruits nothing.
import { RecruitmentPattern } from '@datamoc/mwg/ai';
const pattern = new RecruitmentPattern(['scout', 'fighter'], 'fighter');
console.log(pattern.at(0), pattern.at(1), pattern.at(2)); // scout fighter scout
Suggester
class
Wraps a decision function as a throttled, non-mutating hint source.
import { Suggester } from '@datamoc/mwg/ai';
import type { AIDecision, AIDecisionInput } from '@datamoc/mwg/ai';
declare const module: { decide(agent: string, input: AIDecisionInput): AIDecision };
declare const input: AIDecisionInput;
const hints = new Suggester((perception) => module.decide('player', perception), { every: 30 });
const fresh = hints.update(input); // null on the 29 throttled frames between hints
const current = hints.latest; // never null between updates
alphaBetaSearch
function
Deterministic minimax with alpha-beta pruning for a game-owned state adapter.
import { alphaBetaSearch } from '@datamoc/mwg/ai';
const result = alphaBetaSearch({
currentPlayer: () => 1,
moves: (state: number) => state < 2 ? [1] : [],
apply: (state: number, move: number) => state + move,
isTerminal: (state: number) => state >= 2,
evaluate: (state: number) => state,
}, 0, { depth: 2 });
console.log(result.move); // 1
defaultWeigh
function
The default stage weight: each factor times the aspect of the same name (an unset aspect weighs 1, so a factor on its own is not erased), minus a keepAwayScore penalty for a candidate's distance.
import { Aspects, defaultWeigh } from '@datamoc/mwg/ai';
const score = defaultWeigh(
{ id: 'charge', action: {}, factors: { aggression: 3 } },
{ aspects: new Aspects({ aggression: 2 }) },
);
console.log(score); // 6
firstWins
function
First-wins reduction over scored root moves: a strictly better score replaces the leader, a tie keeps the earlier move, so the same scores in the same order always choose the same move - the property that keeps a parallel root split ...
import { firstWins } from '@datamoc/mwg/ai';
console.log(firstWins([{ move: 'a', score: 1 }, { move: 'b', score: 1 }], true)); // 'a'
console.log(firstWins([{ move: 'a', score: 1 }, { move: 'b', score: 2 }], true)); // 'b'
goalScore
function
What a stage's own weigh can add for a goal: its priority, or 1 when it has none.
import { goalScore } from '@datamoc/mwg/ai';
console.log(goalScore({ unit: 'u1', kind: 'attack', priority: 3 })); // 3
console.log(goalScore(undefined)); // 0, no goal at all
keepAwayScore
function
How much a candidate at distance loses for being inside a keepAway radius: 0 when there is no radius or the candidate is outside it, otherwise the shortfall as a negative score.
import { keepAwayScore } from '@datamoc/mwg/ai';
console.log(keepAwayScore(1, 3)); // -2, two tiles inside the radius
console.log(keepAwayScore(5, 3)); // 0, outside it
personalScoreView
function
The view one subject has: its own score, and the scores of the allies and enemies it can see.
import { personalScoreView, scoreWith } from '@datamoc/mwg/ai';
const hero = { id: 'hero', side: 'blue', x: 2, y: 2 };
const world = [
hero,
{ id: 'ally', side: 'blue', x: 3, y: 2 },
{ id: 'rat', side: 'red', x: 4, y: 2 },
{ id: 'dragon', side: 'red', x: 9, y: 9 },
];
const worth = (id: string) => ({ hero: 10, ally: 4, rat: -2, dragon: -40 })[id] ?? 0;
...
rootSplits
function
Splits a search at the root: every legal move paired with the position it reaches, in the game's own stable order. A child must be plain structured-cloneable data to cross to a worker; a game whose states hold closures or class instances ...
import { rootSplits } from '@datamoc/mwg/ai';
const game = {
currentPlayer: () => 1,
moves: (state: number) => state < 2 ? [1] : [],
apply: (state: number, move: number) => state + move,
isTerminal: (state: number) => state >= 2,
evaluate: (state: number) => state,
};
const splits = rootSplits(game, 0);
...
scoreWith
function
A view collapsed into the one number a mind compares, according to what that mind cares about.
import { scoreWith, type ScoreView } from '@datamoc/mwg/ai';
const view: ScoreView = { own: 10, allies: 4, enemies: -2, seen: 3 };
console.log(scoreWith(view, { own: 1, allies: 0, enemies: 0 })); // 10 - out for itself
console.log(scoreWith(view, { own: 0, allies: 1, enemies: 0 })); // 4 - holds for the line
console.log(scoreWith(view, { own: 1, allies: 1, enemies: -1 })); // 16 - the enemy is the point
sideScoreView
function
The view a whole side has: every unit on it that the side can see, and every enemy it can see.
import { FactionFog } from '@datamoc/mwg/board';
import { scoreWith, sideScoreView } from '@datamoc/mwg/ai';
const fog = new FactionFog(10, 10);
const world = [
{ id: 'scout', side: 'blue', x: 1, y: 1 },
{ id: 'guard', side: 'blue', x: 7, y: 7 },
{ id: 'rat', side: 'red', x: 3, y: 1 },
];
const worth = (id: string) => ({ scout: 6, guard: 9, rat: -3 })[id] ?? 0;
...
subjectsWhere
function
The subjects a filter selects, matching only the attributes the filter names.
import { subjectsWhere } from '@datamoc/mwg/ai';
const world = [
{ id: 'hero', side: 'blue', x: 0, y: 0, role: 'leader', can_recruit: true },
{ id: 'grunt', side: 'blue', x: 1, y: 0 },
];
console.log(subjectsWhere(world, { can_recruit: true }).map((subject) => subject.id)); // ['hero']
ai/lua
LuaAI
class
Optional Lua provider with the same decision shape as JavaScriptAI.
import { LuaAI } from '@datamoc/mwg/ai/lua';
const ai = new LuaAI({ seed: 7 });
ai.register({ id: 'guard', source: 'return { type = "wait" }' });
console.log(ai.decide('guard', { perception: null }).action?.type); // 'wait'
createLuaAI
function
Convenience wrapper that builds a default LuaAI.
import { createLuaAI } from '@datamoc/mwg/ai/lua';
const ai = createLuaAI({ seed: 7 });
ai.register({ id: 'guard', source: 'return { type = "wait" }' });
console.log(ai.decide('guard', { perception: null }).action?.type); // 'wait'
assets
- audio
- battle
- colour-transform
- dialogue
- dungeon
- interface
- loading
- movement
- mwl-presentation
- string-editor
- village
- world-transition
AssetStream
class
Game-directed background asset staging with bounded, least-recently-used eviction.
import { AssetStream } from '@datamoc/mwg/assets';
const stream = new AssetStream({ budgetBytes: 50_000_000 });
// when a player enters a town, a likely-next zone starts loading in the background
await stream.preload({ id: 'forest', paths: ['forest-tiles.png'], priority: 1 });
LoadAssetsOptions
interface
Actually fetching and decoding assets, which is the half that needs Pixi.
import { load, texture, isLoaded, release } from '@datamoc/mwg/assets';
await load(['tiles.png', 'data/level1.json']);
const tilesTexture = texture('tiles.png');
console.log(isLoaded('tiles.png')); // true
await release(['tiles.png']); // frees the GPU texture once a zone is left for good
LoadBinaryOptions
interface
Raw asset bytes, cached and progress-reported the same way loader.ts's texture cache is - but renderer-free, so a Babylon-only game (or anything else that just wants an ArrayBuffer) gets the same load/isLoaded/release shape a 2D ...
import { loadBinary, getBinary } from '@datamoc/mwg/assets';
import { parseVox, createVoxModel3D } from '@datamoc/mwg/3d';
await loadBinary(['models/rock.vox']);
const model = parseVox(getBinary('models/rock.vox'));
fetchWithByteProgress
function
Fetches url, reporting real bytes transferred as they arrive, and resolves to the complete body as a Blob - handed to URL.createObjectURL to become something assets.load's own texture/audio/JSON decoding can take from there, the ...
import { fetchWithByteProgress } from '@datamoc/mwg/assets';
const blob = await fetchWithByteProgress('https://example.com/big-tileset.png', (progress) => {
if (progress.total) console.log(`${progress.loaded} / ${progress.total} bytes`);
});
const url = URL.createObjectURL(blob);
isBinaryLoaded
function
true once loadBinary has resolved for this path and it has not since been releaseBinaryd
import { isBinaryLoaded } from '@datamoc/mwg/assets';
console.log(isBinaryLoaded('models/rock.vox')); // false until loadBinary resolves for it
loadSvgParts
function
Slice named SVG groups into cached textures, without game-owned renderer calls. Accepts inert SVG source or an asset path (compiled data URIs work under file://). Shared definitions, styles, group attributes and the full viewBox are ...
import { loadSvgParts, releaseSvgParts } from '@datamoc/mwg/assets';
const parts = await loadSvgParts('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><g id="body"><rect width="20" height="10"/></g></svg>', { resolution: 2 });
console.log(parts.body.width);
await releaseSvgParts(parts);
releaseBinary
function
Frees cached bytes; a path never loaded (or already released) is silently skipped.
import { releaseBinary } from '@datamoc/mwg/assets';
releaseBinary(['models/rock.vox']); // frees the cached bytes once the model is placed
releaseSvgParts
function
Release borrowed SVG part textures after their sprites have been destroyed.
import { releaseSvgParts } from '@datamoc/mwg/assets';
await releaseSvgParts({});
setAssetMap
function
Hands resolve/has/paths a game's own path-to-URI map, in place of tools/compile-resources's window.__MWG_ASSETS__ convention.
import { setAssetMap, isCompiled, resolve } from '@datamoc/mwg/assets/paths';
setAssetMap({ 'tiles.png': 'data:image/png;base64,...' });
console.log(isCompiled()); // true
console.log(resolve('tiles.png')); // the data: URI handed in above
setAssetMap(undefined); // back to window.__MWG_ASSETS__ / dev-server mode
svgDataUri
function
The data:image/svg+xml form of a source, ready for the loader.
import { svgDataUri } from '@datamoc/mwg/assets';
console.log(svgDataUri('<svg/>').startsWith('data:image/svg+xml,')); // true
svgScaleVariants
function
One source, one variant per named scale.
import { svgScaleVariants } from '@datamoc/mwg/assets';
const [sharp] = svgScaleVariants('<svg/>', [{ name: '3x', resolution: 3 }]);
console.log(sharp.resolution); // 3
assets/binary
LoadBinaryOptions
interface
Raw asset bytes, cached and progress-reported the same way loader.ts's texture cache is - but renderer-free, so a Babylon-only game (or anything else that just wants an ArrayBuffer) gets the same load/isLoaded/release shape a 2D ...
import { loadBinary, getBinary } from '@datamoc/mwg/assets';
import { parseVox, createVoxModel3D } from '@datamoc/mwg/3d';
await loadBinary(['models/rock.vox']);
const model = parseVox(getBinary('models/rock.vox'));
isBinaryLoaded
function
true once loadBinary has resolved for this path and it has not since been releaseBinaryd
import { isBinaryLoaded } from '@datamoc/mwg/assets';
console.log(isBinaryLoaded('models/rock.vox')); // false until loadBinary resolves for it
releaseBinary
function
Frees cached bytes; a path never loaded (or already released) is silently skipped.
import { releaseBinary } from '@datamoc/mwg/assets';
releaseBinary(['models/rock.vox']); // frees the cached bytes once the model is placed
assets/paths
setAssetMap
function
Hands resolve/has/paths a game's own path-to-URI map, in place of tools/compile-resources's window.__MWG_ASSETS__ convention.
import { setAssetMap, isCompiled, resolve } from '@datamoc/mwg/assets/paths';
setAssetMap({ 'tiles.png': 'data:image/png;base64,...' });
console.log(isCompiled()); // true
console.log(resolve('tiles.png')); // the data: URI handed in above
setAssetMap(undefined); // back to window.__MWG_ASSETS__ / dev-server mode
audio
AUDIO_FORMATS
const
The file extension and MIME type of each export format.
import { AUDIO_FORMATS } from '@datamoc/mwg/audio';
console.log(AUDIO_FORMATS.flac.extension, AUDIO_FORMATS.flac.mime); // 'flac' 'audio/flac'
AUDIO_SETTINGS_KEYS
const
The core.Settings custom keys the audio rows use; sliders hold percent, 0 to 100.
import { AUDIO_SETTINGS_KEYS } from '@datamoc/mwg/audio';
import { Settings } from '@datamoc/mwg/core';
declare const settings: Settings;
settings.setCustom(AUDIO_SETTINGS_KEYS.soften, 40); // 40 percent
AdaptiveMusic
class
Adaptive music from stems that play in lockstep: every stem of a version starts on the same clock tick at the same offset, each through its own gain, so raising or lowering a stem (or a named group of them) with a fade changes the ...
import { AdaptiveMusic, renderMidiStems, parseMidi, toAudioBuffer } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const midiBytes: ArrayBuffer;
const rendered = renderMidiStems(parseMidi(midiBytes), { rhythm: [9], melody: [0], pads: [1] });
const stems = Object.fromEntries(Object.entries(rendered).map(([name, r]) => [name, toAudioBuffer(context, r)]));
const music = new AdaptiveMusic(context);
music.load('calm', stems, { groups: { intensity: ['rhythm', 'melody'] } });
...
AudioBus
class
The four audio lifetimes of a role-playing game over two Channel voices: BGM and BGS loop independently, an ME jingle suspends the BGM and resumes it where it left off, SE plays overlapping one-shots. Replaying the current track only ...
import { AudioBus } from '@datamoc/mwg/audio';
declare const context: AudioContext;
const bus = new AudioBus(context, { load: (_kind, _name) => Promise.resolve(null) });
void bus.playBgm({ name: 'town', volume: 90, pitch: 100, pan: 0 });
console.log(bus.snapshot());
AudioCoreHost
class
Runs a synthesis core on a worker and presents it as an endless BlockSource, so a StreamChannel plays it like any stream while the render cost stays off the frame. Messages go to the core stamped with an output frame and are applied at ...
import { AudioCoreHost, StreamChannel, type AudioCore } from '@datamoc/mwg/audio';
declare const context: AudioContext;
const host = new AudioCoreHost({
sampleRate: 44100,
factory: (rate): AudioCore => ({
sampleRate: rate,
event() {},
sysex() {},
...
AudioExport
class
The export service: renders already-synthesised frames to a WAV, FLAC or Opus file. It owns the decisions around the encoding (which formats this device can offer, a size check before the time is spent, default tags, cancellation, handing ...
import { AudioExport, exportTags } from '@datamoc/mwg/audio';
declare const frames: { sampleRate: number; left: Float32Array; right: Float32Array };
const service = new AudioExport({ defaultTags: exportTags({ game: 'Pontifex' }) });
const bytes = await service.export(frames, { format: 'wav', tags: exportTags({ track: 'Harbour' }) });
console.log(service.formats(), bytes.byteLength); // ['wav'] ...
AudioListener
class
Where sounds are heard from: a game moves one listener to its camera or its party leader each frame, and every SoundSource is mixed against it.
import { AudioListener } from '@datamoc/mwg/audio';
const listener = new AudioListener({ x: 40, y: 12 });
listener.moveTo(41, 12); // following the camera
listener.face(Math.PI / 2); // now facing the world's south
AudioSettings
class
The player's audio settings in one place, over the pieces that already exist: bus volumes apply to an AudioBus (applyToBus), the preferred engine names what resolveMidiSynth is asked for first, the region and output stage describe ...
import { AudioSettings } from '@datamoc/mwg/audio';
const settings = new AudioSettings({ preferredEngine: 'retro:mt32', soften: 0.4 });
settings.setBusVolume('se', 130);
console.log(settings.snapshot().busVolumes.se); // 100, clamped
Channel
class
One buffered voice: a decoded track with a loop region and a playhead the game can read and move. Replaying the same buffer restarts it; changing parameters mid-play never does.
import { Channel } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const buffer: AudioBuffer;
const channel = new Channel(context);
channel.play(buffer, { volume: 0.8, pitch: 1, pan: 0, loop: { start: 1, end: 4 } });
console.log(channel.position());
channel.stop();
DEFAULT_DECODER_LIMITS
const
The decoder limits every guard starts from, before a caller raises or lowers one.
import { DEFAULT_DECODER_LIMITS } from '@datamoc/mwg/audio';
console.log(DEFAULT_DECODER_LIMITS.maxInputBytes); // 16777216
DecodeBudgetError
class
A decoder went past a limit; carries the limit's name so a game can say what to raise or what to refuse.
import { DecodeBudgetError } from '@datamoc/mwg/audio';
const error = new DecodeBudgetError('maxInputBytes', 'tune.nsf is larger than the limit');
console.log(error.limit); // 'maxInputBytes'
ExportTooLargeError
class
An export whose estimated size is over the configured limit.
import { AudioExport, ExportTooLargeError } from '@datamoc/mwg/audio';
declare const frames: { sampleRate: number; left: Float32Array; right: Float32Array };
try {
await new AudioExport({ maxBytes: 1_000_000 }).export(frames, { format: 'wav' });
} catch (error) {
if (error instanceof ExportTooLargeError) console.log(error.estimatedBytes, error.maxBytes);
}
GrooveConverter
class
Writes a template out and reads one back, through JSON (exact) or MIDI (encoded, with the bounds above enforced on the way in). Asking for .agr or .reaper-groove throws naming what is missing rather than returning something that merely ...
import { GrooveConverter, type GrooveTemplate } from '@datamoc/mwg/audio';
declare const template: GrooveTemplate;
const converter = new GrooveConverter();
const text = await converter.exportFormat(template, 'json');
const copy = await converter.importFormat(text, 'json'); // same offsets, same name
const bytes = await converter.exportFormat(template, 'midi');
const fromMidi = await converter.importFormat(bytes, 'midi');
...
GrooveExtractor
class
Extracts a groove template from a pair of performances of the same material.
import { parseMidi, GrooveExtractor, type GrooveTemplate } from '@datamoc/mwg/audio';
declare const straightBytes: ArrayBuffer;
declare const playedBytes: ArrayBuffer;
const straight = parseMidi(straightBytes);
const played = parseMidi(playedBytes);
const template = await new GrooveExtractor().extractFromMidiPair(straight, played, '16n');
console.log(template.offsets[0].timeOffsetTicks, template.name); // 0 'extracted'
GrooveHumanizer
class
Tier 2: the lite pass plus the groove grid and two musical rules the lite tier has no opinion about. The template supplies the per-slot deviations, rescaled from the tempo it was measured at to the tempo of the file being played, so a ...
import { parseMidi, GrooveHumanizer, type GrooveTemplate } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
declare const template: GrooveTemplate;
const file = parseMidi(midiBytes);
const engine = new GrooveHumanizer();
await engine.initialize(); // idempotent; the factory already does this for you
const swung = await engine.process(file, { intensity: 0.8, grooveTemplate: template, seed: 7 });
HumanizerFactory
class
One entry point for "humanize this file", so a game never names the class that does the work. The tier decides the engine; everything else about the request is the same for both shipped tiers, which is why the request extends the options ...
import { HumanizerFactory, humanizeMidi, parseMidi } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const file = parseMidi(midiBytes);
//the convenience path: the tier follows from the request
const lite = await humanizeMidi(file, { intensity: 0.5, seed: 1 });
//or hold the engine, when several files go through the same one
...
INTERNAL_MIDI_ENGINES
const
The two engine names that are not synths: they mean "the internal path", SoundFont when one is given, else the built-in approximation.
import { INTERNAL_MIDI_ENGINES } from '@datamoc/mwg/audio';
console.log(INTERNAL_MIDI_ENGINES.includes('builtin')); // true
InstructionBudget
class
A count of emulated instructions an emulator spends as it runs. The emulator calls spend from its dispatch loop (once per instruction, or once per batch); past the allowance it throws, so a tune that loops forever without producing sound ...
import { InstructionBudget } from '@datamoc/mwg/audio';
const budget = InstructionBudget.forSeconds(30);
function runFrame(cycles: number): void {
budget.spend(cycles);
}
runFrame(20000);
console.log(budget.remaining);
LiteHumanizer
class
Tier 1: every note gets a seeded gaussian nudge plus a slow correlated drift, and nothing else. No grid, no model, no I/O - which is exactly why it is the tier a shipped game can afford to call, and why it has to refuse a groove template ...
import { parseMidi, LiteHumanizer } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const file = parseMidi(midiBytes);
const humanized = await new LiteHumanizer().process(file, {
intensity: 0.6,
timingVarianceMs: 6,
seed: 42, // the same seed gives back the same performance every time
});
Music
class
Background music with crossfade: switching tracks fades the old one out and the new one in over the same span, rather than cutting straight from one to the other. Like AnimatedSprite, nothing advances on its own - call update(dt) from ...
import { Music } from '@datamoc/mwg/audio';
const music = new Music({ volume: 0.7 });
music.play('music/town.mp3', 2); // crossfades in over 2 seconds
// in the game loop:
music.update(1 / 60);
MusicPlayer
class
One music track at a time with the controls a game's jukebox needs: load a decoded track or a rendered MIDI file, play, pause, resume, read the position, seek, and be told when the track ends so the game can pick the next one. It wraps a ...
import { MusicPlayer, renderMidiToBufferAsync } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const midiBytes: ArrayBuffer;
declare function nextTrack(): Promise<ArrayBuffer>;
const player = new MusicPlayer(context, { volume: 0.8 });
player.load(await renderMidiToBufferAsync(midiBytes));
player.play();
async function playNext(): Promise<void> {
...
Orchestrator
class
The data a game writes once instead of scattering Music.play/Sound.play calls across every place a fight starts or a menu opens: name the states ("exploring", "combat", "boss", "menu") and the cues ("hit", "levelUp"), and call ...
import { Orchestrator, Music, Sound } from '@datamoc/mwg/audio';
const orchestrator = new Orchestrator(new Music());
orchestrator.define('exploring', { track: 'music/town.mp3' });
orchestrator.define('combat', { track: 'music/battle.mp3', fadeDuration: 0.5 });
orchestrator.on('levelUp', new Sound('sounds/level-up.mp3'));
orchestrator.enter('combat'); // crossfades in
orchestrator.enter('combat'); // already playing - a no-op, does not refade
orchestrator.trigger('levelUp'); // fires the one-shot cue
OutputRecorder
class
Records what flows through some nodes of a WebAudio graph as plain stereo frames, ready for AudioExport: the nodes are tapped (they keep playing to wherever they already went), a script processor collects their blocks, and stop hands ...
import { AudioExport, OutputRecorder } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const master: AudioNode;
const recorder = new OutputRecorder(context, [master], { maxSeconds: 30 });
// ... the player plays for a while ...
const take = recorder.stop();
const wav = await new AudioExport().export(take, { format: 'wav' });
console.log(take.truncated, wav.byteLength);
SAMPLE_CODECS
const
every codec a SoundFont 3 sample may use here, in the order a tool lists them
import { SAMPLE_CODECS } from '@datamoc/mwg/audio';
console.log(SAMPLE_CODECS.join(', ')); // vorbis, opus, flac
SfxArbiter
class
The shared voice limit between chip effects and the bus's 'se' jingles: both ask here before they sound, and when the limit is reached the lowest-priority voice (the oldest among equals) is cut for a new one of equal or higher priority; ...
import { SfxArbiter } from '@datamoc/mwg/audio';
const arbiter = new SfxArbiter({ maxVoices: 2 });
const a = arbiter.request(1, () => console.log('a cut'));
arbiter.request(1, () => console.log('b cut'));
arbiter.request(5, () => console.log('c cut')); // cuts a, the oldest of the lowest priority
a?.release();
SfxSound
class
The data-driven counterpart of Sound: it plays an SfxDefinition (or the id of one in a bank) through a backend, with the same play(gain, pitch) shape so a SoundSource can attenuate it, and a pan on top. It applies the definition's ...
import { SfxArbiter, SfxBank, SfxSound, SoundSource, AudioListener, type SfxBackend } from '@datamoc/mwg/audio';
declare const chip: SfxBackend;
const bank = new SfxBank([{ id: 'arrow', priority: 5, caption: 'an arrow flies' }]);
const arrow = new SfxSound(chip, 'arrow', { bank, arbiter: new SfxArbiter(), frame: () => 0 });
new SoundSource(arrow, { x: 3, y: 4 }).playFor(new AudioListener());
Sound
class
A sound effect, pooled so the same clip can overlap itself. A dozen arrows landing in the same second must not cut each other off, which a single <audio> element would - the next play just steals the oldest of the pool, round-robin, ...
import { Sound } from '@datamoc/mwg/audio';
const hit = new Sound('sounds/hit.mp3', { poolSize: 6, caption: 'a sword strikes' });
hit.play(); // safe to call several times in the same second
SoundSource
class
One positional one-shot: a Sound attached to a point in the world, heard from a listener with the distance gain audioGain computes. playFor applies that gain through Sound.play(gain), and stays silent past maxDistance rather than ...
import { AudioListener, Sound, SoundSource } from '@datamoc/mwg/audio';
const listener = new AudioListener({ x: 0, y: 0 });
const arrow = new Sound('sounds/arrow.mp3');
const source = new SoundSource(arrow, { x: 6, y: 8, refDistance: 4, maxDistance: 20 });
const gain = source.gainTo(listener); // 0.4 at distance 10
source.playFor(listener); // plays at that gain, or not at all when silent
StreamChannel
class
The streaming twin of Channel: it queues short buffers on the audio clock ahead of the playhead from a BlockSource instead of owning one decoded buffer, so a long or endless track costs a couple of seconds of memory rather than the ...
import { StreamChannel, type BlockSource } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const tune: BlockSource;
const channel = new StreamChannel(context);
channel.onEnded.add(() => console.log('done'));
channel.play(tune, { repeat: false, volume: 0.8 });
console.log(channel.position());
channel.stop(1);
StreamPlayable
class
A BlockSource behind the Playable surface, so Music and Orchestrator can play a synthesised stream without knowing it is one. The behaviours HTMLAudioElement leaves implicit, decided here once:
import { StreamPlayable, type BlockSource } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const tune: BlockSource;
const stream = new StreamPlayable(context, tune);
stream.loop = true;
stream.volume = 0.7;
void stream.play();
SyxBank
class
A .syx bank a player dropped: its messages, the DX7 patch names in it, and an audition that loads a patch into a MidiSynth and renders one note through it, so a settings screen can show the names and let the player hear one before ...
import { SyxBank, type MidiSynth } from '@datamoc/mwg/audio';
declare const bankBytes: Uint8Array;
declare const synth: MidiSynth;
const bank = new SyxBank(bankBytes);
console.log(bank.patches.map((patch) => patch.name));
const heard = bank.audition(synth, bank.patches[0]);
console.log(heard.duration > 0); // true
TrackCache
class
A least-recently-used cache of loaded tracks under a BusBudgetPolicy. A load in flight is shared by everyone who asks for the same key, a failure or a null answer is not remembered so the next request retries, and a track that is ...
import { TrackCache, type LoadedTrack } from '@datamoc/mwg/audio';
declare function decode(path: string): Promise<LoadedTrack>;
const cache = new TrackCache({ maxMegabytes: 64 });
const track = await cache.load('music/town.mid', () => decode('music/town.mid'));
console.log(cache.stats().bytes, track?.buffer.duration);
assertGrooveTemplate
function
Checks every invariant of a GrooveTemplate at once, so a template read from a file or handed in from outside fails here rather than as a misaligned grid several frames later. The engines call it before applying a template, and the JSON ...
import { assertGrooveTemplate, subdivisionCount } from '@datamoc/mwg/audio';
const template = {
name: 'four-on-the-floor',
timeSignature: [4, 4] as [number, number],
ppq: 480,
tempoBpm: 120,
subdivision: '16n' as const,
offsets: Array.from({ length: subdivisionCount([4, 4], '16n') }, (_, index) => ({
subdivisionIndex: index,
...
assertInputSize
function
Throws when input is bigger than maxInputBytes, before a decoder has touched it.
import { assertInputSize } from '@datamoc/mwg/audio';
declare const bytes: Uint8Array;
assertInputSize(bytes, { maxInputBytes: 1_000_000 }, 'tune.nsf');
audioGain
function
The gain a listener at distance hears a source at, clamped to [0, 1]: 1 at or inside refDistance, 0 at or beyond maxDistance, an inverse-distance curve between them.
import { audioGain } from '@datamoc/mwg/audio';
audioGain(0, { refDistance: 2 }); // 1 - inside the full-volume radius
audioGain(2, { refDistance: 2 }); // 1 - the reference distance itself
audioGain(4, { refDistance: 2 }); // 0.5 - one doubling away
audioGain(4, { refDistance: 2, maxDistance: 4 }); // 0 - at the edge of hearing
audioPan
function
Stereo pan in [-1, 1] for a source: -1 is hard left of the listener, 1 hard right, 0 directly ahead or behind. The listener's facing is its heading in radians (0 faces +x, increasing clockwise on a screen where y grows downward), so ...
import { audioPan } from '@datamoc/mwg/audio';
const listener = { x: 0, y: 0, facing: 0 }; // facing east
audioPan(listener, { x: 10, y: 0 }); // 0 - straight ahead
audioPan(listener, { x: 0, y: 10 }); // 1 - to the listener's right
audioPan(listener, { x: 0, y: -10 }); // -1 - to its left
audioSettingsRows
function
The accessibility and fidelity rows a player-facing settings screen shows: softening of bright and broadband sounds, loudness dynamics, caption text, the output stage and the region. Pass them as the custom option of SettingsScreen, ...
import { audioSettingsRows } from '@datamoc/mwg/audio';
console.log(audioSettingsRows().map((row) => row.key)); // ['audio.soften', 'audio.dynamics', ...]
auditEngines
function
Renders a calibration note per program through each engine and reports what a listener would call broken before anyone listens: an engine that was not used (the fallback told through onMidiSynthFallback), a note that comes out silent, ...
import { auditEngines } from '@datamoc/mwg/audio';
const report = auditEngines({ engines: ['builtin'], programs: [0], notes: [69] });
console.log(report.rendered, report.findings.length);
benchEngine
function
Times an engine on voices notes held at once for seconds of audio (a chord spread across the keyboard, one channel each), and reports how much of real time the synthesis uses. This is the measurement behind "does it fit the main ...
import { benchEngine } from '@datamoc/mwg/audio';
const result = benchEngine({ engine: 'builtin', voices: 16, seconds: 5 });
console.log(result.ratio < 1 ? 'keeps up' : 'does not keep up');
bufferBytes
function
Memory a decoded buffer holds: 32-bit samples on every channel.
import { bufferBytes } from '@datamoc/mwg/audio';
console.log(bufferBytes({ length: 44100, numberOfChannels: 2 })); // 352800, one stereo second
channelEnvelopes
function
Per-channel waveform envelopes of stereo frames: channel 0 is the left, 1 the right, and a view asks each channel for the same number of buckets.
import { channelEnvelopes } from '@datamoc/mwg/audio';
const [left] = channelEnvelopes(new Float32Array([1, -1]), new Float32Array([0.5, -0.5]), 1);
console.log(left); // [{ min: -1, max: 1 }]
collectSoundFontUsage
function
Every sampled voice the given MIDI files can request, as (bank, program) pairs: the channel voice in effect at each scheduled note, with drums mapped to bank 128 the same way the renderer maps them. A game's compile step feeds this ...
import { parseMidi, collectSoundFontUsage } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
console.log(usage.length);
connectWebMidi
function
Wires every input of a Web MIDI access to a MidiSynth: channel messages go to event, system-exclusive ones (framed by 0xF0 and 0xF7) to sysex, and everything else (clock, active sensing, a message with no data) is dropped, because a ...
import { connectWebMidi, type MidiSynth, type WebMidiAccess } from '@datamoc/mwg/audio';
declare const access: WebMidiAccess;
declare const synth: MidiSynth;
const link = connectWebMidi(access, synth);
console.log(link.ports); // how many keyboards are playing into the synth
link.disconnect();
createAudio
function
The real thing: an HTMLAudioElement pointed at a resolved asset path.
import { createAudio, Sound } from '@datamoc/mwg/audio';
// Sound/Music both default to this; supply your own only for a test or a non-browser backend
const hit = new Sound('sounds/hit.mp3', { create: createAudio });
createAudioLoader
function
One loader for AudioBus music, jingles and effects: MIDI is rendered on a worker, other formats use the browser's audio decoder. MIDI detection uses the file header, so an archive alias does not need a filename extension. Results are ...
import { AudioBus, createAudioLoader } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const soundFontBytes: ArrayBuffer;
const bus = new AudioBus(context, {
load: createAudioLoader(context, { midi: { soundFontBytes, missingProgram: 'synth' } }),
});
await bus.playBgm({ name: 'music/theme.mid', volume: 80, pitch: 100, pan: 0 });
await bus.playSe({ name: 'sounds/hit.wav', volume: 80, pitch: 110, pan: 20 });
createBudgetedLoader
function
Wraps any BusLoader in a TrackCache: the bus keeps asking by kind and name, and the cache decides what stays in memory. The key includes the kind, since a jingle and a theme may share a name.
import { AudioBus, createBudgetedLoader, type BusLoader } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const decode: BusLoader;
const load = createBudgetedLoader(decode, { maxMegabytes: 48, maxEntries: 12 });
const bus = new AudioBus(context, { load });
console.log(load.cache.stats());
decodeFlac
function
Decodes a FLAC stream to mono 16-bit frames and the rate its STREAMINFO states. A stereo stream keeps its left channel; a sample size other than 16 bits is shifted to 16. The stream's own length and rate are checked against maxFrames ...
import { decodeFlac, encodeFlac } from '@datamoc/mwg/audio';
const { frames, rate } = decodeFlac(encodeFlac(Int16Array.from([1, 2, 3]), 8000));
console.log(frames.length, rate); // 3 8000
decodeSoundFont
function
Decodes a SoundFont 3 into a plain SoundFont 2: every compressed sample a preset reaches (Ogg Vorbis, Ogg Opus or FLAC, told apart by its first bytes) goes through decode (one call per sample, given the codec, returning mono 16-bit ...
import { decodeSoundFont, webAudioSampleDecoder } from '@datamoc/mwg/audio';
declare const sf3Bytes: ArrayBuffer;
const sf2 = await decodeSoundFont(sf3Bytes, webAudioSampleDecoder());
console.log(sf2.byteLength);
decodeWav
function
Decodes a PCM (8, 16, 24 or 32 bit) or float32 WAVE file to stereo frames: a mono file is duplicated to both channels, and channels past the second are dropped.
import { decodeWav } from '@datamoc/mwg/audio';
declare const bytes: Uint8Array;
const { sampleRate, left } = decodeWav(bytes);
console.log(sampleRate, left.length);
decoderLimits
function
Merges a partial override over the defaults and rejects values that cannot be limits.
import { decoderLimits } from '@datamoc/mwg/audio';
console.log(decoderLimits({ maxSeconds: 600 }).maxSeconds); // 600
detectSampleCodec
function
Names the codec of one compressed sample from its first bytes, or throws naming what it found. An Ogg stream is Vorbis when its first packet starts \x01vorbis and Opus when it starts OpusHead; a stream beginning fLaC is FLAC.
import { detectSampleCodec } from '@datamoc/mwg/audio';
console.log(detectSampleCodec(new TextEncoder().encode('fLaC')));
downloadSaver
function
Saves through a temporary link with the download attribute, the way every browser accepts.
import { downloadSaver } from '@datamoc/mwg/audio';
downloadSaver(new Blob([new Uint8Array(4)], { type: 'audio/wav' }), 'tune.wav');
dx7PatchNames
function
The patch names of a .syx bank: every DX7 32-voice bulk dump in it contributes its 32 voices, whose names are the last ten bytes of each packed voice. Messages that are not whole, checksummed DX7 banks contribute nothing (their names ...
import { dx7PatchNames } from '@datamoc/mwg/audio';
declare const bankBytes: Uint8Array;
console.log(dx7PatchNames(bankBytes)); // ['BRASS1', 'STRINGS', ...] or [] for another maker's bank
dx7VoiceInfo
function
The algorithm, feedback, LFO wave and transposition of one packed 128-byte DX7 voice (the bytes after the six 17-byte operator blocks and the pitch envelope).
import { dx7VoiceInfo } from '@datamoc/mwg/audio';
const voice = new Uint8Array(128);
voice[110] = 4; //algorithm 5
voice[111] = 6; //feedback 6
console.log(dx7VoiceInfo(voice).algorithm, dx7VoiceInfo(voice).feedback); // 5 6
dynamicsLimit
function
One sample through the dynamics control: at 0 the sample is untouched, and as the amount rises a soft knee pulls peaks toward full scale without ever passing it, so a loud game gets quieter peaks rather than clipped ones.
import { dynamicsLimit } from '@datamoc/mwg/audio';
console.log(dynamicsLimit(0.5, 0)); // 0.5, untouched
console.log(dynamicsLimit(4, 1) < 1); // true, a hot sample is pulled under full scale
encodeFlac
function
Encodes mono 16-bit frames as a FLAC stream (fLaC, STREAMINFO, then frames), lossless: the decoder returns exactly frames. rate goes into STREAMINFO; the MD5 field stays zero, which the format defines as "not computed".
import { decodeFlac, encodeFlac } from '@datamoc/mwg/audio';
const flac = encodeFlac(Int16Array.from([0, 100, 200, 100, 0]), 22050);
console.log(decodeFlac(flac).rate); // 22050
encodeSoundFont
function
Compresses a SoundFont 2 into a SoundFont 3: every sample a preset reaches goes through encode (one call per sample, mono 16-bit frames, the codec named by codec, options passed along: an Opus bitrate or a Vorbis quality level) and ...
import { encodeSoundFont, flacSampleEncoder } from '@datamoc/mwg/audio';
declare const sf2Bytes: ArrayBuffer;
const sf3 = await encodeSoundFont(sf2Bytes, flacSampleEncoder, 'flac');
console.log(sf3.byteLength < sf2Bytes.byteLength);
encodeWav
function
Writes stereo frames as a canonical RIFF/WAVE file: 16-bit PCM by default, or 32-bit float when the samples must survive unclipped. The one container every tool and browser reads, which is why the offline renders of this module go out ...
import { encodeWav, renderMidiToBuffer, parseMidi } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const wav = encodeWav(renderMidiToBuffer(parseMidi(midiBytes)));
console.log(wav.byteLength);
estimateExportBytes
function
A rough size in bytes for an export, before spending the time on it: WAV is exact (about 10 MB a minute in stereo at 44.1 kHz), Opus follows its bit rate, FLAC takes about 60 percent of the 16-bit PCM it holds (a rule of thumb for ...
import { estimateExportBytes } from '@datamoc/mwg/audio';
console.log(Math.round(estimateExportBytes({ format: 'wav' }, 60, 44100) / 1e6)); // 11
fitToBudget
function
Fits a rendered track to a budget: cuts it to maxSeconds with a fade, folds it to mono and halves its rate, in that order. A mono track shares one array between both channels, so it costs half the memory in the renderer's hands even ...
import { fitToBudget, renderMidiToBuffer, parseMidi } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const ambience = fitToBudget(renderMidiToBuffer(parseMidi(midiBytes)), { maxSeconds: 60, mono: true, halfRate: true });
console.log(ambience.duration);
flacSampleDecoder
const
{@link decodeFlac} as a {@link SoundFontSampleDecoder}: FLAC samples only, so a font written with flacSampleEncoder loads in Node or a browser with no WebAudio
import { encodeFlac, flacSampleDecoder } from '@datamoc/mwg/audio';
const flac = encodeFlac(Int16Array.from([0, 1, 2]), 22050);
console.log(flacSampleDecoder(flac, 22050, 'flac'));
flacSampleEncoder
const
{@link encodeFlac} as a {@link SoundFontSampleEncoder}, for encodeSoundFont(font, flacSampleEncoder, 'flac')
import { flacSampleEncoder } from '@datamoc/mwg/audio';
console.log((await flacSampleEncoder(Int16Array.from([0, 1, 2]), 22050, 'flac')).length);
guardBlockSource
function
Wraps a BlockSource so a misbehaving decoder cannot hurt the game: a read that claims more frames than it was given room for is an error, non-finite or wildly loud samples are zeroed or clamped, the stream ends at maxSeconds whatever ...
import { guardBlockSource, type BlockSource } from '@datamoc/mwg/audio';
declare const untrusted: BlockSource;
const safe = guardBlockSource(untrusted, { maxSeconds: 600 });
humanizeMidi
function
Humanizes in one call: resolves the tier, runs it, returns the new file.
import { humanizeMidi, parseMidi, type GrooveTemplate } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
declare const template: GrooveTemplate;
const file = parseMidi(midiBytes);
const swung = await humanizeMidi(file, {
tier: 'groove',
intensity: 0.7,
grooveTemplate: template,
...
inflateBounded
function
Inflates a raw DEFLATE stream while counting the output, so a few kilobytes that expand into gigabytes are refused after maxOutputBytes, not after they have filled memory.
import { inflateBounded } from '@datamoc/mwg/audio';
declare const packed: Uint8Array;
const bytes = await inflateBounded(packed, { maxOutputBytes: 1_000_000 });
isOpusEncodingAvailable
function
Whether this environment can encode Opus itself, which is whether a WebCodecs AudioEncoder exists. False in Node and in older WebViews, where the game injects another encoder (or leaves Opus out: AudioExport.formats() follows this).
import { isOpusEncodingAvailable } from '@datamoc/mwg/audio';
console.log(isOpusEncodingAvailable()); // true in a current Chrome
loadSoundFont
function
Reads a SoundFont 2 or 3 into layered preset voices: parseSoundFont for a plain font, and a SoundFont 3 decoded first through decode, webAudioSampleDecoder() (the browser) unless one is given. This is what a game calls on the bytes ...
import { loadSoundFont, parseMidi, renderMidiToBuffer } from '@datamoc/mwg/audio';
declare const fontBytes: ArrayBuffer;
declare const midiBytes: ArrayBuffer;
const font = await loadSoundFont(fontBytes);
const rendered = renderMidiToBuffer(parseMidi(midiBytes), { soundfont: font });
console.log(rendered.duration);
loopRegionFromFile
function
The loop region a track's own file declares, as the default when the game gives none: the region from loopStartSeconds to the end (or to loopEndSeconds when the file has one), null when the file declares no loop or the declaration ...
import { loopRegionFromFile } from '@datamoc/mwg/audio';
console.log(loopRegionFromFile({ loopStartSeconds: 8, durationSeconds: 60 })); // { start: 8, end: 60 }
loopRegionFromTags
function
The loop region in seconds for decoded audio, from a file's declared tags: the start, and the start plus the length (or the end of the buffer when no length is declared). Null when the tags name no usable region inside the buffer.
import { loopRegionFromTags } from '@datamoc/mwg/audio';
console.log(loopRegionFromTags({ loopStart: 44100, loopLength: 44100 }, 44100, 10)); // { start: 1, end: 2 }
mergeSoundFonts
function
Joins several SoundFont 2 files into one, presets in the order given. When two files carry the same (bank, program) the first wins and the other's preset, instruments and samples are dropped, so the result never holds a voice nothing can ...
import { mergeSoundFonts, parseSoundFont } from '@datamoc/mwg/audio';
declare const pianoBytes: ArrayBuffer;
declare const drumsBytes: ArrayBuffer;
const merged = mergeSoundFonts([pianoBytes, drumsBytes]);
console.log(parseSoundFont(merged).presetCount);
midiLoopStart
function
Seconds from the start of playback at which the file loops (its controller-111 marker through the file's own tempo map), or null when the file carries no marker and loops from the start instead.
import { parseMidi, midiLoopStart } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const file = parseMidi(midiBytes);
console.log(midiLoopStart(file)); // seconds, or null with no loop marker
midiSynthNames
function
Every name a request can use: the registered engines and the two internal ones.
import { midiSynthNames } from '@datamoc/mwg/audio';
console.log(midiSynthNames()); // ['gm-soundfont', 'builtin', ...registered engines]
missingSoundFontPrograms
function
The programs of usage the font does not carry, so a game can tell at build time which of the instruments its MIDI files ask for would be missing. A bank without the program counts as carried when bank 0 has it (the fallback every player ...
import { missingSoundFontPrograms, parseMidi, collectSoundFontUsage } from '@datamoc/mwg/audio';
declare const soundfontBytes: ArrayBuffer;
declare const midiBytes: ArrayBuffer;
const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
for (const { bank, program } of missingSoundFontPrograms(soundfontBytes, usage)) {
console.log(`bank ${bank} program ${program} is not in the font`);
}
oggOpus
function
Wraps Opus packets (stereo, 20 ms each) in an Ogg stream: an OpusHead page, an OpusTags page, then audio pages whose granule positions count 48 kHz samples from the start including the pre-skip, the last one cut to totalSamples so a ...
import { oggOpus } from '@datamoc/mwg/audio';
const silence = Uint8Array.of(0xf8, 0xff, 0xfe); //a 20 ms Opus frame of silence
const file = oggOpus([silence, silence], { inputRate: 44100, totalSamples: 1920 });
console.log(new TextDecoder().decode(file.subarray(0, 4))); // 'OggS'
onAudioUnderrun
const
The one place every audio underrun is reported, whichever voice had it. A game forwards this to its telemetry (TelemetryClient, which bounds what it sends) or shows it in a settings panel; the voices also have their own signals for code ...
import { onAudioUnderrun } from '@datamoc/mwg/audio';
let dropouts = 0;
onAudioUnderrun.add(() => {
dropouts++;
});
onCaption
const
Fires whenever a captioned Sound plays - a captioning overlay subscribes here, entirely decoupled from Sound itself, which never imports anything UI-shaped.
import { onCaption } from '@datamoc/mwg/audio';
onCaption.add(({ text }) => showCaptionOverlay(text));
declare function showCaptionOverlay(text: string): void;
onMidiSynthFallback
const
Fires on every fallback, so a game can show "MT-32 unavailable, using the SoundFont" instead of a silent change of sound.
import { onMidiSynthFallback } from '@datamoc/mwg/audio';
onMidiSynthFallback.add(({ requested, used }) => console.log(requested, '->', used));
parseMidi
function
Reads format 0/1 Standard MIDI File bytes into a flat, tick-ordered event list.
import { parseMidi, scheduleMidi, noteToFrequency, MidiPlayer } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const file = parseMidi(midiBytes);
const notes = scheduleMidi(file); // absolute-time notes, ready to voice
console.log(noteToFrequency(69)); // 440 - A4
const player = new MidiPlayer(file, { waveform: 'triangle' });
player.play();
...
parseSoundFont
function
Reads SoundFont 2 bytes into layered preset voices.
import { parseSoundFont, parseMidi, renderMidiToBuffer } from '@datamoc/mwg/audio';
declare const soundfontBytes: ArrayBuffer;
declare const midiBytes: ArrayBuffer;
const font = parseSoundFont(soundfontBytes);
const rendered = renderMidiToBuffer(parseMidi(midiBytes), { soundfont: font });
console.log(font.presetCount, rendered.duration);
parseVorbisLoopTags
function
Reads the LOOPSTART and LOOPLENGTH comments (samples) that RPG-style Ogg Vorbis music carries, or null when the file names no loop. Walks the Ogg pages to the Vorbis comment packet rather than scanning for text, and never reads past ...
import { parseVorbisLoopTags } from '@datamoc/mwg/audio';
declare const oggBytes: Uint8Array;
console.log(parseVorbisLoopTags(oggBytes)); // { loopStart, loopLength } or null
playMidiHosted
function
Plays a MIDI file through a synthesis core that runs off the main thread: builds an AudioCoreHost (a worker where one exists, the main thread otherwise, mode: 'auto' unless told), queues the file's messages on it and hands its output ...
import { StreamChannel, parseMidi, playMidiHosted, type AudioCore } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const midiBytes: ArrayBuffer;
declare const factory: (rate: number) => AudioCore;
const running = playMidiHosted(parseMidi(midiBytes), {
sampleRate: context.sampleRate,
factory,
channel: new StreamChannel(context),
...
playTone
function
Synthesizes a tone and plays it once, fire-and-forget - the runtime counterpart to Sound.play, generating instead of loading. create is injectable the same way Sound/Music already are, so a test never needs a real Audio element.
import { playTone } from '@datamoc/mwg/audio';
playTone({ waveform: 'sine', frequency: 660, duration: 0.08 }); // a quick UI blip
readSyx
function
Splits a .syx file like splitSysex and also says what it left out: bytes before the first 0xF0 (leading-bytes), an unterminated final message (unterminated), a DX7-shaped bank whose checksum fails (bad-checksum), and whole ...
import { readSyx } from '@datamoc/mwg/audio';
const { messages, skipped } = readSyx(new Uint8Array([1, 2, 0xf0, 0x41, 0xf7]));
console.log(messages.length, skipped); // 1 [{ kind: 'leading-bytes', bytes: 2 }, ...]
regionPlaybackRate
function
The playback rate a tune authored for one region runs at on the other: a PAL machine's 50 Hz clock against NTSC's 60 Hz makes the same file slower and lower, which is the difference a player who grew up with the other standard hears first.
import { regionPlaybackRate } from '@datamoc/mwg/audio';
console.log(regionPlaybackRate('pal', 'ntsc')); // 0.8333..., slower on PAL
console.log(regionPlaybackRate('ntsc', 'ntsc')); // 1, its own region
registerMidiSynth
function
Registers a named engine so content can say synth: "retro:mt32". A name that is already registered is replaced, and the two internal names are reserved.
import { registerMidiSynth, type MidiSynth } from '@datamoc/mwg/audio';
declare function createMt32(sampleRate: number): MidiSynth;
registerMidiSynth('retro:mt32', createMt32);
renderMidiStems
function
Renders one file as several stems that line up: each named group of channels is rendered on its own, and every stem is padded with silence to the length of the longest, so they start together, loop together and can be mixed by level for ...
import { parseMidi, renderMidiStems } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const stems = renderMidiStems(parseMidi(midiBytes), { rhythm: [9], melody: [0, 1], pads: [2] });
console.log(stems.rhythm.duration === stems.melody.duration); // true
renderMidiThroughSynth
function
Renders a MIDI file through a MidiSynth to stereo frames, the same RenderedMidi the SoundFont path produces, so it plays, loops and seeks like any other track. Messages are delivered at their exact sample, not rounded to a block, so a ...
import { parseMidi, renderMidiThroughSynth, type MidiSynth } from '@datamoc/mwg/audio';
declare const synth: MidiSynth;
declare const midiBytes: ArrayBuffer;
const rendered = renderMidiThroughSynth(parseMidi(midiBytes), synth);
console.log(rendered.sampleRate, rendered.duration);
renderMidiToBuffer
function
Renders a parsed MIDI file to stereo PCM frames: one offline pass, then the result plays, loops, seeks and re-pitches like any decoded track. Deterministic: the same file and options always produce the same frames, so a test can render and ...
import { parseMidi, renderMidiToBuffer } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
const rendered = renderMidiToBuffer(parseMidi(midiBytes));
console.log(rendered.duration, rendered.loopStart, rendered.loopEnd);
renderMidiToBufferAsync
function
Renders raw MIDI bytes to stereo PCM frames on a worker thread, so the work that costs seconds on a real track (T161 measured 2 to 11.8) does not freeze the loading screen the caller is presumably drawing. Everything cheap stays on this ...
import { renderMidiToBufferAsync } from '@datamoc/mwg/audio';
declare const midi: ArrayBuffer;
declare const soundFont: ArrayBuffer;
const track = await renderMidiToBufferAsync(midi, { soundFontBytes: soundFont });
console.log(track.duration, track.loopStart, track.loopEnd);
reportUnderruns
function
Forwards audio underruns to a telemetry sink as audio_underrun events, counted and summed so a bad minute is one event, not a thousand: each event carries count, lateSeconds and source (stream, core, or mixed), and no more ...
import { TelemetryClient } from '@datamoc/mwg/core';
import { reportUnderruns } from '@datamoc/mwg/audio';
const telemetry = new TelemetryClient({ endpoint: 'https://example.com/api/telemetry' });
const reporter = reportUnderruns(telemetry);
reporter.stop();
requestWebMidiInput
function
Asks the browser for Web MIDI and wires it to synth: null where the browser has no Web MIDI (or the player refused it), so a game offers the live keyboard only where it can exist. System-exclusive is requested, since a bank dump arrives ...
import { requestWebMidiInput, type MidiSynth } from '@datamoc/mwg/audio';
declare const synth: MidiSynth;
const link = await requestWebMidiInput(synth);
console.log(link === null ? 'no live input here' : `${link.ports} port(s) live`);
resampleStereo
function
Converts stereo frames to another sample rate by linear interpolation. Linear is audibly soft above the new Nyquist, which is the right trade for ambience and background music where this is used to save memory; a game that needs a clean ...
import { resampleStereo } from '@datamoc/mwg/audio';
declare const frames: { sampleRate: number; left: Float32Array; right: Float32Array };
const half = resampleStereo(frames, frames.sampleRate / 2);
console.log(half.left.length);
resolveMidiSynth
function
Walks a request in order and settles on the first engine that works: a registered name is looked up, a factory is called, an internal name ends the walk. An unknown name, a factory that throws or a synth with a bad shape is skipped, and ...
import { resolveMidiSynth } from '@datamoc/mwg/audio';
const { synth, name } = resolveMidiSynth(['retro:mt32', 'retro:opl2'], 44100, 'gm-soundfont');
console.log(name, synth === null ? 'internal path' : synth.sampleRate);
sendMidiToHost
function
Starts a MIDI file on a host: resets the core, drops what was rendered ahead and queues every channel message at its own output frame in one post (before any further audio is requested, so a worker never renders the first seconds without ...
import { AudioCoreHost, parseMidi, sendMidiToHost, type AudioCore } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
declare const factory: (rate: number) => AudioCore;
const host = new AudioCoreHost({ sampleRate: 44100, factory });
const lastFrame = sendMidiToHost(host, parseMidi(midiBytes));
console.log(lastFrame / 44100);
sfxSeed
function
A reproducible seed for an effect: the same id on the same game frame (and the same ordinal within that frame) always gives the same number, so a replay or a lockstep peer hears exactly what the original run heard, and audio never perturbs ...
import { sfxSeed } from '@datamoc/mwg/audio';
sfxSeed('arrow', 120) === sfxSeed('arrow', 120); // true
softenCutoff
function
The low-pass cutoff, in hertz, that a soften amount asks an output stage for: nothing at 0, down to a muffled 3.5 kHz at 1, never above what the sample rate can represent.
import { softenCutoff } from '@datamoc/mwg/audio';
console.log(softenCutoff(0, 44100)); // 18000, effectively open
console.log(softenCutoff(1, 44100) < softenCutoff(0.5, 44100)); // true, more soften, lower cutoff
spectralDistance
function
How far apart two renders sound, as the mean absolute difference of their log spectra: the number to watch when one synthesis engine is tuned toward another's reference, or when a change must not alter a render. Both are folded to mono and ...
import { spectralDistance } from '@datamoc/mwg/audio';
declare const reference: { sampleRate: number; left: Float32Array; right: Float32Array };
declare const candidate: { sampleRate: number; left: Float32Array; right: Float32Array };
console.log(spectralDistance(reference, candidate).decibels);
splitSysex
function
The system-exclusive messages of a .syx file, in file order, each framed by its 0xF0 and 0xF7. Bytes before the first 0xF0 and an unterminated final message are not messages and are left out, because a bank file a player dropped is ...
import { splitSysex } from '@datamoc/mwg/audio';
const messages = splitSysex(new Uint8Array([0xf0, 0x41, 0xf7, 0xf0, 0x42, 0xf7]));
console.log(messages.length); // 2
subdivisionCount
function
How many slots a bar of this signature has on this grid: the number of subdivisions that fit in beats * (4 / beatValue) quarters. It throws when the grid does not tile the bar exactly (an eighth grid against a bar of three sixteenths), ...
import { subdivisionCount } from '@datamoc/mwg/audio';
console.log(subdivisionCount([4, 4], '16n')); // 16: a bar of 4/4 cut into sixteenths
console.log(subdivisionCount([6, 8], '8n')); // 6: a bar of 6/8 cut into eighths
subsetSoundFont
function
Writes a smaller, valid SoundFont 2 file carrying only the used programs: their presets (names kept), instruments and samples, with zones, ranges, tuning, envelopes and sample loops copied verbatim and every index remapped. Zones that ...
import { parseMidi, collectSoundFontUsage, subsetSoundFont } from '@datamoc/mwg/audio';
declare const midiBytes: ArrayBuffer;
declare const soundfontBytes: ArrayBuffer;
const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
const pruned = subsetSoundFont(soundfontBytes, usage);
console.log(usage.length, pruned.byteLength);
synthesizeSound
function
A pooled Sound playing a synthesized tone: synthesizeTone and Sound composed, for the common case of a procedural effect that must overlap itself.
import { synthesizeSound } from '@datamoc/mwg/audio';
const blip = synthesizeSound({ waveform: 'square', frequency: 660, duration: 0.08 }, { poolSize: 6 });
blip.play(1, 0.9 + Math.random() * 0.2); // a little pitch variance per hit
synthesizeTone
function
Renders a short tone to a data:audio/wav URI, ready to hand to an Audio element (or any other Playable source) without touching the network or mwg/assets - nothing here is loaded, only computed.
import { synthesizeTone } from '@datamoc/mwg/audio';
const dataUri = synthesizeTone({ waveform: 'square', frequency: 880, duration: 0.1 });
const audio = new Audio(dataUri);
timedMidiMessages
function
Every channel message of a file (notes, program changes, controllers, pitch bend) as raw MIDI bytes at its playback time, in file order. The sampled renderer works from scheduleMidi's paired notes; a MidiSynth is driven by these ...
import { timedMidiMessages, type MidiFile } from '@datamoc/mwg/audio';
declare const file: MidiFile;
console.log(timedMidiMessages(file)[0]?.status); // 0x90 for a first note-on, say
toAudioBuffer
function
Copies a rendered MIDI track into a stereo AudioBuffer of the given context. The frames are written through getChannelData, so the Float32Array views a render hands back need no conversion first.
import { parseMidi, renderMidiToBuffer, toAudioBuffer } from '@datamoc/mwg/audio';
declare const context: AudioContext;
declare const midiBytes: ArrayBuffer;
const buffer = toAudioBuffer(context, renderMidiToBuffer(parseMidi(midiBytes)));
trackerNoteName
function
A MIDI note number as a tracker spells it: name and octave, middle C as C4.
import { trackerNoteName } from '@datamoc/mwg/audio';
console.log(trackerNoteName(60)); // 'C4'
console.log(trackerNoteName(61)); // 'C#4'
trackerPattern
function
A MIDI file as tracker pattern rows: stepsPerQuarter rows per quarter note, one column per channel that plays, and a cell naming the note that starts on that row (the highest when a channel starts several at once, as a tracker cell holds ...
import { trackerPattern, type MidiFile } from '@datamoc/mwg/audio';
declare const file: MidiFile;
const rows = trackerPattern(file);
console.log(rows[0].notes); // the first step's notes by channel, or nulls
unregisterMidiSynth
function
Forgets a named engine; returns whether it was registered.
import { unregisterMidiSynth } from '@datamoc/mwg/audio';
console.log(unregisterMidiSynth('retro:mt32')); // true when it was registered
wavInfo
function
Reads a WAVE header: layout and length, no samples.
import { wavInfo } from '@datamoc/mwg/audio';
declare const bytes: Uint8Array;
console.log(wavInfo(bytes).sampleRate);
waveformEnvelope
function
A waveform reduced to buckets min/max pairs, what an oscilloscope or overview strip draws: each bucket covers an equal span of the samples (the last may be shorter), so a peak that lasts one sample still shows, which a plain average ...
import { waveformEnvelope } from '@datamoc/mwg/audio';
const view = waveformEnvelope(new Float32Array([0, 1, -1, 0.5]), 2);
console.log(view); // [{ min: 0, max: 1 }, { min: -1, max: 0.5 }]
webAudioSampleDecoder
function
A decoder over WebAudio's decodeAudioData, for a browser: each sample decodes in a context created at the sample's own rate, so the browser does not resample it and the loop points keep meaning what the font wrote. A rate outside what a ...
import { loadSoundFont, webAudioSampleDecoder } from '@datamoc/mwg/audio';
declare const sf3Bytes: ArrayBuffer;
const font = await loadSoundFont(sf3Bytes, { decode: webAudioSampleDecoder() });
console.log(font.presetCount);
webCodecsOpusEncoder
const
An ExportEncoder for Opus over WebCodecs: brings the frames to 48 kHz, feeds 20 ms blocks to an AudioEncoder at the requested bit rate (128 kbit/s when omitted), and wraps the packets in an Ogg stream with the request's tags. Browser ...
import { AudioExport, isOpusEncodingAvailable, webCodecsOpusEncoder } from '@datamoc/mwg/audio';
const service = new AudioExport({
encoders: { opus: webCodecsOpusEncoder },
opusAvailable: isOpusEncodingAvailable,
});
console.log(service.formats());
battle
AttackDialog
class
The attack dialog's whole state, with no renderer: the unit selector, the damage preview the game's numbers build, and the animation clock the preview is sampled on.
import { AttackDialog } from '@datamoc/mwg/battle';
const dialog = new AttackDialog({
units: [{ id: 'hero', side: '1' }, { id: 'rat', side: '2' }],
side: '1',
damageFor: () => ({ damage: 4, strikes: 2 }),
strikeDuration: 0.25,
});
dialog.select();
dialog.select();
...
AttackPreview
class
The numbers an attack dialog shows and animates: the attacker and defender's hp over the course of a blow, plus the totals the labels print.
import { AttackPreview } from '@datamoc/mwg/battle';
const preview = new AttackPreview({
attacker: { hp: 20 },
defender: { hp: 12 },
damage: 4,
strikes: 3,
chanceToHit: 0.6,
strikeDuration: 0.2,
});
...
BattleHook
interface
A named-event registry whose handlers can be removed in bulk by whatever registered them.
import { HookRegistry } from '@datamoc/mwg/core';
const hooks = new HookRegistry<[amount: number]>();
const poisonRing = {};
hooks.on('turnStart', (amount) => console.log('poison ticks for', amount), poisonRing);
hooks.emit('turnStart', 3);
// the ring is removed - every hook it registered comes off in one call
hooks.offSource(poisonRing);
BattleHooks
class
A battle-scoped place for passive effects to run code, not just push a number - the seam mwg/actors' StatBlock modifiers and applyStatusEffect do not cover, since both are numeric-only. An ability reacting to "this creature switched ...
import { BattleHooks } from '@datamoc/mwg/battle';
const hooks = new BattleHooks<{ name: string }>();
const paralysis = {};
hooks.on('turnStart', (creature, context) => {
if ((context as { skip: boolean }).skip === undefined) return;
(context as { skip: boolean }).skip = true; // this creature cannot act this turn
}, paralysis);
...
BattleStatCategory
type
A per-run battle statistics breakdown by category and unit type - recruits, recalls, advances, kills, deaths, damage dealt, damage taken - the shape Wesnoth's own statistics dialog shows and a bare core.PlayerStats<T> cannot give a game ...
import { BattleStats } from '@datamoc/mwg/battle';
const stats = new BattleStats();
stats.record('kills', 'orc-grunt');
stats.record('kills', 'orc-grunt');
stats.record('damageDealt', 'elvish-archer', 12);
console.log(stats.total('kills')); // 2
console.log(stats.breakdown('kills')); // [{ unitType: 'orc-grunt', count: 2 }]
Creature
class
One individual: a species, a level and experience (via mwg/actors' Progression), and a StatBlock so it can carry the same equipment modifiers any other actor can. Reuses both rather than reimplementing them, since a creature's stats ...
import { Creature, type Species } from '@datamoc/mwg/battle';
const fireling: Species = { id: 'fireling', types: ['fire'], baseStats: { attack: 10, speed: 8 } };
const wild = new Creature({ species: fireling, level: 5 });
console.log(wild.stats.get('attack'));
FieldCondition
interface
Named conditions the battle itself carries - weather, terrain, a screen like Reflect - rather than any one creature's own StatBlock. Every modifier-shaped thing mwg/actors has (equipment, buffs, status effects) is scoped to one ...
import { Field } from '@datamoc/mwg/battle';
const field = new Field();
field.set({ id: 'rain', duration: 5 });
if (field.has('rain')) console.log('water moves are boosted');
field.advance(); // one round passes; duration counts down, expiring at 0
Party
class
A roster with a limited active lineup and unlimited off-party storage - a box, a PC, whatever a game calls it. Active slots can hold a gap (a fainted, withdrawn, or simply empty slot); storage is just an overflow list in the order ...
import { Party, Creature, type Species } from '@datamoc/mwg/battle';
const species: Species = { id: 'fireling', types: ['fire'], baseStats: { attack: 10 } };
const party = new Party<Creature>(3); // 3 active slots
party.add(new Creature({ species })); // fills an active slot, then overflows to storage
console.log(party.members.length, party.boxed.length);
StatStagesOptions
interface
A bounded, symmetric stage ladder over a StatBlock stat - the Swords Dance/Screech shape, where a stat is boosted or lowered in discrete steps, capped at some number of steps either direction, and reset entirely on switch-out. ...
import { StatStages } from '@datamoc/mwg/battle';
import { StatBlock } from '@datamoc/mwg/actors';
const stats = new StatBlock({ base: { attack: 10 } });
const stages = new StatStages(stats, { max: 6, multiplier: (stage) => 1 + stage * 0.5 });
stages.change('attack', 2); // two stages up
console.log(stats.get('attack')); // 20
stages.resetAll(); // switching out clears every stage at once
TypeMatrix
class
An effectiveness matrix between arbitrary types - mwg supplies the matrix, not a type chart. Type names are whatever strings a game defines; an unset pairing multiplies by 1, so a matrix only has to name the pairings that are not neutral.
import { TypeMatrix } from '@datamoc/mwg/battle';
const matrix = new TypeMatrix();
matrix.set('fire', 'grass', 2); // super effective
matrix.set('fire', 'water', 0.5); // not very effective
const total = matrix.multiplierFor('fire', ['grass', 'ice']); // 2 * 1 = 2
UnitSelector
class
The two-step unit selector an attack starts with: pick who attacks, then pick what they attack.
import { UnitSelector } from '@datamoc/mwg/battle';
const selector = new UnitSelector({
units: [{ id: 'hero', side: '1' }, { id: 'rat', side: '2' }],
side: '1',
});
selector.select(); // hero attacks
console.log(selector.targets.map((unit) => unit.id)); // ['rat']
Whiteboard
class
The planned-but-uncommitted orders of a turn - Wesnoth's whiteboard - with undo across them.
import { Whiteboard } from '@datamoc/mwg/battle';
const board = new Whiteboard<{ unit: string; x: number; y: number }>();
board.plan({ unit: 'hero', x: 2, y: 3 });
board.plan({ unit: 'archer', x: 5, y: 1 });
board.undo();
console.log(board.plans); // [{ unit: 'hero', x: 2, y: 3 }]
battleOrder
function
Turn order by speed, with move priority overriding it - one round's worth of ordering, not a persistent queue. mwg/roguelike's Scheduler already covers continuous energy-cost time for a dungeon; a battle round picks everyone's action ...
import { battleOrder, type BattleAction } from '@datamoc/mwg/battle';
const actions: BattleAction<string>[] = [
{ actor: 'hero', speed: 12 },
{ actor: 'wolf', speed: 18 },
{ actor: 'hero-item', speed: 12, priority: 1 }, // items go first regardless of speed
];
const order = battleOrder(actions).map((a) => a.actor); // ['hero-item', 'wolf', 'hero']
checkEvolution
function
The last rule whose condition holds at this level, or null when none does - the same "ordered low to high, most-specific-that-matches wins" convention mwg/rpg's event pages use, so a chain authored [16 -> adult, 32 -> elder] reaches ...
import { checkEvolution, type EvolutionRule } from '@datamoc/mwg/battle';
const rules: EvolutionRule<string>[] = [
{ at: (level) => level >= 16, into: 'adult-form' },
{ at: (level) => level >= 32, into: 'elder-form' },
];
const evolvedInto = checkEvolution(rules, 40); // 'elder-form'
chooseMove
function
Picks the move whose type is most effective against opponentTypes, ties keeping the first candidate; null for an empty list.
import { chooseMove, chooseSwitch, TypeMatrix } from '@datamoc/mwg/battle';
const matrix = new TypeMatrix();
matrix.set('fire', 'grass', 2);
const moves = [{ type: 'fire' }, { type: 'normal' }];
const best = chooseMove(moves, matrix, ['grass']); // the fire move
const bench = [{ types: ['water'] }, { types: ['grass'] }];
const swapTo = chooseSwitch(['grass'], bench, matrix, ['fire']); // index of the water one
board
BoardGrid
class
A generic rectangular grid of pieces - the shared shape underneath a game-specific board (chess, checkers, or a custom game entirely) that only needs cells, bounds checking, and moving a piece from one cell to another.
import { BoardGrid } from '@datamoc/mwg/board';
const board = new BoardGrid<{ id: string; owner: string; kind: string }>(3, 3);
board.set(1, 1, { id: 'king', owner: 'white', kind: 'king' });
console.log(board.move({ x: 1, y: 1 }, { x: 2, y: 2 })); // { id: 'king', owner: 'white', kind: 'king' }
console.log(board.get(2, 2)); // the same piece, now at its new cell
console.log(board.get(1, 1)); // null - the old cell is empty
DiceCup
class
A cup of dice a player can keep and re-roll - Yahtzee's own mechanic, generalised to any count/sides.
import { DiceCup } from '@datamoc/mwg/board';
import { Random } from '@datamoc/mwg/core';
const cup = Random.withSeed(1, () => new DiceCup(5, 6));
console.log(cup.values); // [5, 2, 6, 1, 1]
cup.keep(2); // keep the 6
cup.reRoll(); // every other die rolls again; index 2 stays a 6
FactionFog
class
Unions the visible cells of several units for each faction and retains explored memory. The caller supplies visibility because board games differ in terrain, range, and blockers.
import { FactionFog } from '@datamoc/mwg/board';
const fog = new FactionFog(10, 10);
const sources = [{ x: 2, y: 2 }];
fog.sync('blue', sources, (source) => [
source,
{ x: source.x + 1, y: source.y },
]);
...
SkirmishTerrain
interface
A Wesnoth-style hex army-game rules layer: terrain that costs different movement points and grants a defence bonus per terrain kind, adjacent attacks that always let a surviving defender strike back, capturable villages that grant income ...
import { startingSkirmish, setSkirmishTerrain, addSkirmishUnit, skirmishMoves, moveSkirmishUnit, skirmishAttack, endSkirmishTurn, skirmishIncome } from '@datamoc/mwg/board';
const terrain = { plain: { moveCost: 1, defense: 0 }, forest: { moveCost: 2, defense: 0.3 } };
const state = startingSkirmish(6, 6, terrain);
setSkirmishTerrain(state, 2, 2, 'forest');
setSkirmishTerrain(state, 0, 0, 'plain', true); // a village at the corner
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 4, hitChance: 0.7 });
addSkirmishUnit(state, { id: 'raider', owner: 'red', x: 1, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 3, hitChance: 0.6 });
...
TacticalShape
type
A tactical action-point battle layer: a grid or hex board where units spend action points to move and attack, with overwatch reactions and turn order rotating by owner. Distances and neighbours follow shape, reusing core.Hex for hex ...
import {
startingTactics,
addTacticalUnit,
tacticalMoves,
moveTacticalUnit,
setTacticalOverwatch,
triggerTacticalOverwatch,
tacticalAttack,
endTacticalTurn,
} from '@datamoc/mwg/board';
...
addSkirmishUnit
function
Places a unit at full remaining movement; the first unit added decides whose turn it is.
import { startingSkirmish, addSkirmishUnit } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 4, hitChance: 0.7 });
console.log(state.turn); // 'blue'
addTacticalUnit
function
Places a unit; the first unit added decides whose turn it is.
import { startingTactics, addTacticalUnit } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
console.log(state.turn); // 'blue'
applyBackgammonMove
function
import { startingBackgammon, backgammonMoves, applyBackgammonMove } from '@datamoc/mwg/board';
const state = startingBackgammon();
applyBackgammonMove(state, backgammonMoves(state, [1])[0]);
console.log(state.points[0], state.points[1]); // 1 1 - one checker moved from point 0 to point 1
applyCheckersMove
function
import { startingCheckers, checkersMoves, applyCheckersMove } from '@datamoc/mwg/board';
const state = startingCheckers();
applyCheckersMove(state, checkersMoves(state)[0]);
console.log(state.turn); // 'black' - the move used no capture, so the turn passes
applyMove
function
Plays a move, mutating the position. Refuses anything legalMoves would not list - a silent illegal move is how a minigame corrupts its own board.
import { startingChess, sq, applyMove } from '@datamoc/mwg/board';
const state = startingChess();
applyMove(state, { from: sq('e2'), to: sq('e4') });
console.log(state.turn); // 'black'
console.log(state.board[sq('e4')]); // { side: 'white', kind: 'pawn' }
applyUpkeep
function
import { startingArmy, startingTactics, addTacticalUnit, applyUpkeep } from '@datamoc/mwg/board';
const board = startingTactics(6, 6, 'square');
addTacticalUnit(board, { id: 'scout', owner: 'blue', x: 0, y: 0, hp: 6, maxHp: 6, actions: 2 });
const army = startingArmy(100);
applyUpkeep(army, board, 'blue', { incomePerUnit: 5, upkeepPerUnit: 2 });
console.log(army.currency); // 103 - this turn's net income applied
armyIncome
function
import { startingTactics, addTacticalUnit, armyIncome } from '@datamoc/mwg/board';
const board = startingTactics(6, 6, 'square');
addTacticalUnit(board, { id: 'scout', owner: 'blue', x: 0, y: 0, hp: 6, maxHp: 6, actions: 2 });
console.log(armyIncome(board, 'blue', { incomePerUnit: 5, upkeepPerUnit: 2 })); // 3 - one unit, net of upkeep
backgammonMoves
function
import { startingBackgammon, backgammonMoves } from '@datamoc/mwg/board';
const moves = backgammonMoves(startingBackgammon(), [1]);
console.log(moves.length, moves[0]); // 3 { from: 0, to: 1, die: 1 }
bankUnit
function
import { startingArmy, recruit, bankUnit, startingTactics } from '@datamoc/mwg/board';
const board = startingTactics(6, 6, 'square');
const army = startingArmy(100);
recruit(army, board, { id: 'scout', owner: 'blue', x: 0, y: 0, hp: 6, maxHp: 6, actions: 2 }, 30);
bankUnit(army, board, 'scout');
console.log(board.units.length, army.pool.length); // 0 1 - pulled off the map, into the pool
bitboardFromChess
function
Reads a chess.ts position onto bitboards: the bridge the differential tests cross in both directions, and the way a FEN reaches the fast board.
import { bitboardFromChess, bitboardMoves, startingChess } from '@datamoc/mwg/board';
const moves = bitboardMoves(bitboardFromChess(startingChess()));
console.log(moves.length); // 20 - the opening position's own count
bitboardMoves
function
Every legal move for the side to play, in stable square order: pseudo-moves that leave their own king safe, each tried with makeMove and taken back with unmakeMove. Castling needs its rights, an empty passage and no check on the way ...
import { bitboardMoves, startingBitboard } from '@datamoc/mwg/board';
const moves = bitboardMoves(startingBitboard());
console.log(moves.every((move) => move.from >= 0 && move.to < 64)); // true
canPlaceSkirmishUnit
function
whether a unit could be placed at (x, y) right now - on the map, passable, and unoccupied
import { startingSkirmish, addSkirmishUnit, canPlaceSkirmishUnit } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 4, hitChance: 0.7 });
console.log(canPlaceSkirmishUnit(state, 1, 0)); // true
console.log(canPlaceSkirmishUnit(state, 0, 0)); // false - axeman is already there
canPlaceTacticalUnit
function
whether a unit could be placed at (x, y) right now - on the map, and not already occupied
import { startingTactics, addTacticalUnit, canPlaceTacticalUnit } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
console.log(canPlaceTacticalUnit(state, 1, 1)); // true
console.log(canPlaceTacticalUnit(state, 0, 0)); // false - ranger is already there
checkersMoves
function
Legal moves for whoever's turn it is - captures only, when one is available, since a capture is forced in checkers; forcedFrom narrows this to one piece mid-multi-jump.
import { startingCheckers, checkersMoves } from '@datamoc/mwg/board';
const moves = checkersMoves(startingCheckers());
console.log(moves.length); // 7 - every red piece on the back row can step forward
chessGame
const
The chess rules adapter consumed by the shared ai.alphaBetaSearch primitive.
import { chessGame, startingChess } from '@datamoc/mwg/board';
import { alphaBetaSearch } from '@datamoc/mwg/ai';
const result = alphaBetaSearch(chessGame, startingChess(), { depth: 2 });
console.log(result.move !== null); // true
chooseMove
function
Chooses a move with a small negamax alpha-beta search.
import { chooseMove } from '@datamoc/mwg/board';
import { startingChess, applyMove } from '@datamoc/mwg/board';
const state = startingChess();
const move = chooseMove(state, { depth: 2 });
if (move) applyMove(state, move); // the engine's own reply, played for it
cloneChess
function
A deep copy, safe to mutate (via applyMove) without touching the original - what legalMoves uses to try a move and check it does not leave the mover in check.
import { startingChess, cloneChess } from '@datamoc/mwg/board';
const state = startingChess();
const trial = cloneChess(state);
console.log(trial !== state, trial.turn); // true 'white'
createDeck
function
import { createDeck } from '@datamoc/mwg/board';
console.log(createDeck().length); // 52
console.log(createDeck(2).length); // 54 - two jokers added
deal
function
import { createDeck, deal } from '@datamoc/mwg/board';
const hands = deal(createDeck(), 4, 13);
console.log(hands.length, hands[0].length); // 4 13 - a full deck dealt to four players
dealSolitaire
function
A Klondike-shaped deal: seven tableau columns, each with one face-up card on a run of face-down ones, the rest in the stock.
import { dealSolitaire } from '@datamoc/mwg/board';
const state = dealSolitaire(1); // seeded, for a reproducible deal
console.log(state.stock.length); // 24 - 52 minus the 28 dealt across the tableau
console.log(state.tableau[0].up); // [{ suit: 'clubs', rank: 12 }] - one face-up card to start
drawSolitaire
function
Draws from the stock to the waste; once the stock is empty, the waste is turned back over into a new stock rather than the game being stuck.
import { dealSolitaire, drawSolitaire } from '@datamoc/mwg/board';
const state = dealSolitaire(1);
const card = drawSolitaire(state);
console.log(card); // { suit: 'hearts', rank: 7 }
console.log(state.waste.length, state.stock.length); // 1 23
endSkirmishTurn
function
Hands the turn to the next owner with units on the board, refilling their movement and healing every one of their units standing on a village they own.
import { startingSkirmish, addSkirmishUnit, endSkirmishTurn } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 4, hitChance: 0.7 });
addSkirmishUnit(state, { id: 'raider', owner: 'red', x: 3, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 3, hitChance: 0.6 });
endSkirmishTurn(state, 2);
console.log(state.turn, state.round); // 'red' 1
endTacticalTurn
function
Hands the turn to the next owner with units on the board, refreshing their action points and clearing overwatch.
import { startingTactics, addTacticalUnit, endTacticalTurn } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
addTacticalUnit(state, { id: 'raider', owner: 'red', x: 3, y: 0, hp: 10, maxHp: 10, actions: 2 });
endTacticalTurn(state);
console.log(state.turn, state.round); // 'red' 1
gameResult
function
mate, stalemate, or more game: whoever just moved is judged through the side to play
import { startingChess, gameResult } from '@datamoc/mwg/board';
console.log(gameResult(startingChess())); // 'ongoing'
goResult
function
import { startingGo, goResult } from '@datamoc/mwg/board';
console.log(goResult(startingGo(9))); // 'ongoing' - nobody has passed yet
goScore
function
Stones on the board plus surrounded empty territory, per side - area scoring, not the Japanese rule set's separate prisoner count.
import { startingGo, playGo, goScore } from '@datamoc/mwg/board';
const state = startingGo(9);
playGo(state, 4, 4);
console.log(goScore(state)); // { black: 1, white: 0 } - one stone, no territory settled yet
huffDecode
function
The inverse of huffEncode: throws on malformed input rather than guessing, because a corrupt table must fail the load, never misprobe. The symbol count in the header must match the decoded symbols exactly, so truncation fails here ...
import { huffDecode, huffEncode } from '@datamoc/mwg/board';
console.log(huffDecode(huffEncode(new Uint8Array([2, 2])))); // Uint8Array([2, 2])
huffEncode
function
Canonical Huffman plus base64, the game decodes with this same dependency-free code. Two dictionary schemes were tried first and both lost to the data: run-length encoding collapsed because a solved ending is a fine win/draw/loss mosaic ...
import { huffDecode, huffEncode } from '@datamoc/mwg/board';
console.log(huffDecode(huffEncode(new Uint8Array([2, 2, 2, 3])))); // Uint8Array([2, 2, 2, 3])
inCheck
function
import { startingChess, inCheck } from '@datamoc/mwg/board';
console.log(inCheck(startingChess(), 'white')); // false - nobody is in check at the start
loadTablebaseEnding
function
Loads a checked-in ending file into probeable arrays: validates the id against its extras, the position count against the piece count, and both payloads against the count, so a truncated artifact fails here with its ending named.
import { loadTablebaseEnding } from '@datamoc/mwg/board';
const ending = loadTablebaseEnding({ version: 1, id: 'KBK', white: ['bishop'], positions: 2, wdl: 'AgEC', dtm: 'AAQAAQ' });
console.log(ending.id); // 'KBK'
makeMove
function
Plays a move, mutating the position, and returns what unmakeMove needs to take it back. Special moves are read off the position rather than flagged on the move: a pawn onto the en passant square captures the pawn behind it, a king ...
import { bitboardMoves, makeMove, startingBitboard, unmakeMove } from '@datamoc/mwg/board';
const board = startingBitboard();
const move = bitboardMoves(board)[0];
const undo = makeMove(board, move);
console.log(board.turn); // 'black' - the move is played
unmakeMove(board, move, undo);
moveSkirmishUnit
function
import { startingSkirmish, addSkirmishUnit, skirmishMoves, moveSkirmishUnit } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 2, attack: 4, hitChance: 0.7 });
moveSkirmishUnit(state, skirmishMoves(state, 'axeman')[0]);
console.log(state.units[0].remainingMoves); // 1 - one plain hex spent
moveSolitaireTableau
function
Moves the top count face-up cards from one tableau column to another - legal only onto a card one rank higher of the opposite colour, or onto an empty column with a king.
import { dealSolitaire, moveSolitaireTableau } from '@datamoc/mwg/board';
const state = dealSolitaire(1);
moveSolitaireTableau(state, 4, 0, 1); // the jack of hearts onto the queen of clubs
console.log(state.tableau[0].up.at(-1)); // { suit: 'hearts', rank: 11 }
moveSolitaireToFoundation
function
Moves a card to its suit's foundation pile - legal only as the next rank up (an ace starts a pile).
import { dealSolitaire, drawSolitaire, moveSolitaireToFoundation } from '@datamoc/mwg/board';
const state = dealSolitaire(1);
let card = drawSolitaire(state);
while (card && card.rank !== 1) card = drawSolitaire(state); // draw until an ace surfaces
moveSolitaireToFoundation(state, 'waste');
console.log(state.foundations.some((pile) => pile.length === 1)); // true
moveTacticalUnit
function
import { startingTactics, addTacticalUnit, tacticalMoves, moveTacticalUnit } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
addTacticalUnit(state, { id: 'raider', owner: 'red', x: 3, y: 0, hp: 10, maxHp: 10, actions: 2 });
moveTacticalUnit(state, tacticalMoves(state, 'ranger')[0]);
console.log(state.units.find((unit) => unit.id === 'ranger')); // { ..., x: 1, y: 0, actions: 1 }
parseFen
function
Reads a position in Forsyth-Edwards Notation - the shape puzzles come in.
import { parseFen, sq } from '@datamoc/mwg/board';
// Scholar's mate position, black to move, every castling right still on the books
const state = parseFen('r1bqkbnr/pppp1Qpp/2n5/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4');
console.log(state.turn); // 'black'
console.log(state.board[sq('f7')]?.side); // 'white' - the queen that just gave check
parseUciMove
function
Reads a UCI move (e2e4, e7e8q) back into a ChessMove. The inverse of what the book compiler writes; it checks the shape, not the legality, so the caller matches the result against legalMoves before playing it.
import { parseUciMove } from '@datamoc/mwg/board';
console.log(parseUciMove('e2e4')); // { from: 12, to: 28 }
passGo
function
import { startingGo, passGo, goResult } from '@datamoc/mwg/board';
const state = startingGo(9);
passGo(state);
passGo(state); // two passes in a row ends the game
console.log(goResult(state)); // 'finished'
perft
function
Move-generation node count: 1 at depth 0, else the sum over every legal move. The position is played and taken back as it goes, so it reads identical afterwards. Counts match the published perft numbers, which is the whole point: a wrong ...
import { perft, startingBitboard } from '@datamoc/mwg/board';
console.log(perft(startingBitboard(), 2)); // 400 - the opening's two-ply count
playGo
function
Places a stone, captures any enemy group left with no liberties, and enforces ko (a move that would recreate the position before the opponent's last capture is refused).
import { startingGo, playGo } from '@datamoc/mwg/board';
const state = startingGo(9);
playGo(state, 4, 4);
console.log(state.turn); // 'white' - the turn passes after a legal move
positionKey
function
The position half of FEN: placement, side to move, castling rights and en passant square, without either move clock. Two positions share a key exactly when the same moves are legal from both, which is what makes it the lookup key for an ...
import { positionKey, startingChess } from '@datamoc/mwg/board';
console.log(positionKey(startingChess()));
// 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -'
probeBook
function
Looks a position up in the book: its continuations, plus the ECO code and name where a known line ends there (mid-line transpositions carry moves but no name). Pure and total: an unknown key is a miss, never an error.
import { probeBook, startingChess } from '@datamoc/mwg/board';
const book = {
version: 1 as const,
positions: {
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -': {
moves: ['b2b4'],
},
},
};
...
probeTablebase
function
Probes loaded endings for an exact position: its WDL outcome from the side to move's view with the distance, fifty-move-adjudicated against halfmove (a win too far to force in time comes back a draw, and likewise a distant loss). Pure ...
import { probeTablebase, startingChess } from '@datamoc/mwg/board';
console.log(probeTablebase({}, startingChess())); // null - no endings loaded, never an error
recall
function
import { startingArmy, recruit, bankUnit, recall, startingTactics } from '@datamoc/mwg/board';
const board = startingTactics(6, 6, 'square');
const army = startingArmy(100);
recruit(army, board, { id: 'scout', owner: 'blue', x: 0, y: 0, hp: 6, maxHp: 6, actions: 2 }, 30);
bankUnit(army, board, 'scout');
recall(army, board, 'scout', 1, 1);
console.log(board.units.some((unit) => unit.id === 'scout')); // true - fielded again, at its new position
recruit
function
import { startingArmy, recruit, startingTactics } from '@datamoc/mwg/board';
const board = startingTactics(6, 6, 'square');
const army = startingArmy(100);
recruit(army, board, { id: 'scout', owner: 'blue', x: 0, y: 0, hp: 6, maxHp: 6, actions: 2 }, 30);
console.log(army.currency); // 70 - 100 minus the 30 cost
rollBackgammonDice
function
Doubles roll as four of the same die, not two - the standard backgammon rule.
import { rollBackgammonDice } from '@datamoc/mwg/board';
const dice = rollBackgammonDice();
console.log(dice.length === 2 || dice.length === 4); // true
rollDice
function
import { rollDice } from '@datamoc/mwg/board';
import { Random } from '@datamoc/mwg/core';
const rolls = Random.withSeed(1, () => rollDice(2, 6));
console.log(rolls); // [5, 2] - deterministic for seed 1
rollExpression
function
Rolls a "NdS+M"-style expression (2d6+1: two six-sided dice, plus one).
import { rollExpression } from '@datamoc/mwg/board';
import { Random } from '@datamoc/mwg/core';
console.log(Random.withSeed(1, () => rollExpression('2d6+1'))); // 8 - rolls [5, 2], plus 1
scoreDice
function
Scores five d6 values against one Yahtzee category.
import { scoreDice } from '@datamoc/mwg/board';
console.log(scoreDice([4, 4, 4, 4, 4], 'yahtzee')); // 50
console.log(scoreDice([1, 2, 3, 4, 5], 'chance')); // 15 - the sum of all five dice
console.log(scoreDice([4, 4, 4, 4, 4], 'fours')); // 20 - five fours, four points each
search
function
The full negamax result behind chooseMove - the move it would play, its evaluation, and how many positions it visited to get there.
import { search, startingChess } from '@datamoc/mwg/board';
const result = search(startingChess(), { depth: 2 });
console.log(result.move !== null); // true - the opening position always has legal moves
console.log(typeof result.score, typeof result.nodes); // 'number' 'number'
searchAsync
function
Chooses a move the way search does, but scores each root move on a worker thread, so the 90 ms a depth-3 search costs (T161) does not drop the caller's frames. The split is synchronous and cheap; one threads.spawn task per root move ...
import { searchAsync, startingChess } from '@datamoc/mwg/board';
const result = await searchAsync(startingChess(), { depth: 2 });
console.log(result.move !== null); // true - the opening position always has legal moves
searchTourney
function
Chooses a move with the tournament search: the same tourneyThink task the workers run, called directly, so one implementation serves both sides.
import { searchTourney, startingBitboard, tourneyThink } from '@datamoc/mwg/board';
const direct = tourneyThink(startingBitboard(), { depth: 1 });
const result = searchTourney(startingBitboard(), { depth: 2 });
console.log(direct.move !== null && result.move !== null); // true - the opening position always has legal moves
searchTourneyAsync
function
Chooses a move the way searchTourney does, but thinks on worker threads through threads.spawn, so a deep search does not drop the caller's frames. Each root move is a self-contained tourneyThink task over a structured-cloned ...
import { searchTourneyAsync, startingBitboard } from '@datamoc/mwg/board';
const result = await searchTourneyAsync(startingBitboard(), { depth: 2 });
console.log(result.move !== null); // true - the opening position always has legal moves
setSkirmishTerrain
function
import { startingSkirmish, setSkirmishTerrain } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 }, forest: { moveCost: 2, defense: 0.3 } });
setSkirmishTerrain(state, 2, 2, 'forest', true);
console.log(state.cells[2 * 6 + 2]); // { terrain: 'forest', village: true }
setTacticalOverwatch
function
Spends the rest of a unit's turn watching, so it can react through triggerTacticalOverwatch when an enemy moves nearby.
import { startingTactics, addTacticalUnit, setTacticalOverwatch } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
setTacticalOverwatch(state, 'ranger');
console.log(state.units[0].overwatch); // true
shuffleDeck
function
import { createDeck, shuffleDeck } from '@datamoc/mwg/board';
import { Random } from '@datamoc/mwg/core';
const shuffled = Random.withSeed(1, () => shuffleDeck(createDeck()));
console.log(shuffled[0]); // { suit: 'hearts', rank: 6 } - deterministic for seed 1
skirmishAttack
function
An attacker's strike against an adjacent enemy, followed by the defender's own retaliation if it survives - both use the same formula: hitChance reduced by the defender's terrain defence, Random.chance deciding whether it lands, ...
import { startingSkirmish, addSkirmishUnit, skirmishAttack } from '@datamoc/mwg/board';
import { Random } from '@datamoc/mwg/core';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 4, hitChance: 1 });
addSkirmishUnit(state, { id: 'raider', owner: 'red', x: 1, y: 0, hp: 10, maxHp: 10, moves: 4, attack: 3, hitChance: 1 });
const exchange = Random.withSeed(1, () => skirmishAttack(state, 'axeman', 'raider'));
console.log(exchange.strikes.map((strike: { attacker: string }) => strike.attacker)); // ['axeman', 'raider'] - raider survived to retaliate
skirmishIncome
function
Net income for one side, from a flat base plus every village it currently owns - purely a function of the map's own village ownership, no formula beyond that belongs here, the same policy board.Army's armyIncome already follows.
import { startingSkirmish, setSkirmishTerrain, skirmishIncome } from '@datamoc/mwg/board';
const state = startingSkirmish(4, 1, { plain: { moveCost: 1, defense: 0 } });
setSkirmishTerrain(state, 0, 0, 'plain', true);
state.cells[0].owner = 'blue';
console.log(skirmishIncome(state, 'blue', 2, 1)); // 3 - base 2, plus 1 for the one village
skirmishMoves
function
Every cell a unit can reach with its remaining movement points this turn, weighted by each terrain kind's own moveCost - no zone of control, unlike board.Tactics.
import { startingSkirmish, addSkirmishUnit, skirmishMoves } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
addSkirmishUnit(state, { id: 'axeman', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, moves: 2, attack: 4, hitChance: 0.7 });
console.log(skirmishMoves(state, 'axeman').length); // 6 - every hex within 2 plain steps
solitaireWon
function
import { dealSolitaire, solitaireWon } from '@datamoc/mwg/board';
console.log(solitaireWon(dealSolitaire(1))); // false - a fresh deal is never already won
sq
function
'e4' to 28 and back - tests read sq('e4') rather than bare numbers.
import { sq } from '@datamoc/mwg/board';
console.log(sq('a1')); // 0
console.log(sq('e4')); // 28
console.log(sq('h8')); // 63
squareName
function
The inverse of sq: an index 0-63 back to its algebraic name.
import { squareName } from '@datamoc/mwg/board';
console.log(squareName(0)); // 'a1'
console.log(squareName(28)); // 'e4'
console.log(squareName(63)); // 'h8'
startingArmy
function
Recruit/income economy layered on top of Tactics's board: a currency total plus a pool of banked unit templates waiting to be recalled onto the map. See recruit/bankUnit/ recall/armyIncome/applyUpkeep for the rest of the ...
import { startingArmy } from '@datamoc/mwg/board';
console.log(startingArmy(100)); // { currency: 100, pool: [] }
startingBackgammon
function
import { startingBackgammon } from '@datamoc/mwg/board';
const state = startingBackgammon();
console.log(state.points[0], state.points[23]); // 2 -2 - white's and black's back checkers
startingBitboard
function
The standard opening array on bitboards, white to move with every right intact.
import { bitboardMoves, startingBitboard } from '@datamoc/mwg/board';
console.log(bitboardMoves(startingBitboard()).length); // 20 - 16 pawn moves, 4 knight moves
startingCheckers
function
import { startingCheckers } from '@datamoc/mwg/board';
const state = startingCheckers();
console.log(state.turn, state.board.length); // 'red' 64
startingChess
function
The standard opening array, white to move with every right intact.
import { startingChess, legalMoves } from '@datamoc/mwg/board';
const state = startingChess();
console.log(state.turn); // 'white'
console.log(legalMoves(state).length); // 20 - 16 pawn moves, 4 knight moves
startingGo
function
import { startingGo } from '@datamoc/mwg/board';
const state = startingGo(9);
console.log(state.turn, state.board.length); // 'black' 81
startingSkirmish
function
import { startingSkirmish } from '@datamoc/mwg/board';
const state = startingSkirmish(6, 6, { plain: { moveCost: 1, defense: 0 } });
console.log(state.width, state.round); // 6 1
startingTactics
function
import { startingTactics } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
console.log(state.width, state.height, state.round); // 6 6 1
tablebaseId
function
The canonical id for white extras: strongest piece first, so ['bishop', 'queen'] and ['queen', 'bishop'] name the same KQBK ending.
import { tablebaseId } from '@datamoc/mwg/board';
console.log(tablebaseId(['bishop', 'queen'])); // 'KQBK'
tacticalAttack
function
import { startingTactics, addTacticalUnit, endTacticalTurn, tacticalAttack } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
addTacticalUnit(state, { id: 'raider', owner: 'red', x: 1, y: 0, hp: 10, maxHp: 10, actions: 2 });
endTacticalTurn(state); // it's blue's turn until someone ends it
const result = tacticalAttack(state, 'raider', 'ranger', 3);
console.log(result); // { attacker: 'raider', defender: 'ranger', damage: 3, cover: 0, killed: false }
tacticalMoves
function
Every cell a unit can reach with its remaining action points, respecting terrain, occupancy, and enemy zones of control.
import { startingTactics, addTacticalUnit, tacticalMoves } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 0, y: 0, hp: 10, maxHp: 10, actions: 2 });
addTacticalUnit(state, { id: 'raider', owner: 'red', x: 3, y: 0, hp: 10, maxHp: 10, actions: 2 });
const moves = tacticalMoves(state, 'ranger');
console.log(moves.length, moves[0]); // 5 { unit: 'ranger', x: 1, y: 0, cost: 1 }
trickWinner
function
Highest card of the trump suit wins; failing that, highest card of whoever led. Ace (rank 1) ranks lowest, as in belote/tarot/bridge follow-suit play.
import { trickWinner } from '@datamoc/mwg/board';
const plays = [
{ owner: 'north', card: { suit: 'hearts' as const, rank: 5 as const } },
{ owner: 'south', card: { suit: 'hearts' as const, rank: 10 as const } },
{ owner: 'east', card: { suit: 'spades' as const, rank: 13 as const } },
];
console.log(trickWinner(plays)); // 'south' - highest heart, hearts led
console.log(trickWinner(plays, 'spades')); // 'east' - the only spade played beats every heart
triggerTacticalOverwatch
function
Fires every enemy overwatch reaction in range of the moving unit.
import { startingTactics, addTacticalUnit, setTacticalOverwatch, endTacticalTurn, triggerTacticalOverwatch } from '@datamoc/mwg/board';
const state = startingTactics(6, 6, 'square');
addTacticalUnit(state, { id: 'ranger', owner: 'blue', x: 1, y: 0, hp: 10, maxHp: 10, actions: 2 });
addTacticalUnit(state, { id: 'raider', owner: 'red', x: 3, y: 0, hp: 10, maxHp: 10, actions: 2 });
setTacticalOverwatch(state, 'ranger');
endTacticalTurn(state);
const reactions = triggerTacticalOverwatch(state, 'raider', 4); // raider steps into range
console.log(reactions); // [{ attacker: 'ranger', defender: 'raider', damage: 4, cover: 0, killed: false }]
unmakeMove
function
Takes back exactly what makeMove played: the mover returns, the taken piece (or the en passant pawn behind the landing square) is restored, and rights, en passant square, halfmove clock and king square come back from the undo.
import { bitboardMoves, makeMove, startingBitboard, unmakeMove } from '@datamoc/mwg/board';
const board = startingBitboard();
const move = bitboardMoves(board)[0];
const undo = makeMove(board, move);
unmakeMove(board, move, undo);
console.log(board.turn, bitboardMoves(board).length); // 'white' 20 - taken back whole
core
- battle
- bridge
- chess
- colour-transform
- dungeon
- headless
- interface
- loading
- minigame
- movement
- mwl-presentation
- neural
- pacman
- save-load
- tower-defense
- village
- world-transition
AchievementCriterion
interface
Achievements: named milestones unlocked by counters crossing a target - monsters slain, gold collected, floors descended, runs won.
import { Achievements } from '@datamoc/mwg/core';
const achievements = new Achievements();
achievements.define({ id: 'first-blood', counter: 'kills', target: 1 });
achievements.define({ id: 'slayer', counter: 'kills', target: 100 });
achievements.define({
id: 'diverse-army',
criteria: [
{ counter: 'recruited-archer', target: 1 },
{ counter: 'recruited-knight', target: 1 },
...
ActionJournal
class
A serializable, append-only action log for replay, undo checkpoints, synchronization and debugging. It does not interpret actions or events: the game decides how to reapply an action, while the journal preserves the exact order and ...
import { ActionJournal } from '@datamoc/mwg/core';
const journal = new ActionJournal<string, { type: string }>();
journal.append('open-door', [{ type: 'door-opened' }]);
const checkpoint = journal.mark();
// An undo system can restore its state and replay journal.since(checkpoint).
Blob
class
A spreading volume field over a grid: a number per cell that creeps into its open neighbours and thins out over time. Fire, gas, caustic ooze, flood water - anything with a volume per cell.
import { Blob } from '@datamoc/mwg/core';
const fire = new Blob(20, 20);
fire.seed(5, 5, 10); // a fireball lands
// one turn of spreading; it hands back whatever it just burned out, so the ground under
// the fire can turn to embers at the moment the flames leave it
const burntOut = fire.spread((x, y) => x >= 0 && y >= 0 && x < 20 && y < 20, 0.25, 0.9);
for (const cell of burntOut) console.log('burned out at', cell.x, cell.y);
...
CanonicalState
class
A single JSON-safe source of truth for a game's logical state. The root value belongs to the game, while framework or game subsystems register independent versioned extensions beside it. Reads and writes clone values, so a renderer cannot ...
import { CanonicalState } from '@datamoc/mwg/core';
const state = new CanonicalState({ turn: 1, gold: 10 });
state.update((current) => ({ ...current, gold: current.gold - 3 }));
const save = state.snapshot();
state.restore(save);
Collection
class
A named collection of records, queried and filtered - the shape SaveSystem does not cover. A save slot is one blob loaded wholesale; a collection is many small records asked questions: where((q) => !q.done) is "everything not yet ...
import { Collection } from '@datamoc/mwg/core';
const quests = new Collection('quests');
quests.put({ id: 'rats', stage: 1, done: false });
quests.put({ id: 'shopkeeper', stage: 3, done: true });
const active = quests.where((q) => !q.done);
console.log(quests.size, active.length); // 2 1
CsvColumnType
type
A header-row CSV into an array of typed row objects - so a content designer edits a spreadsheet instead of a .ts object literal for a table shaped like actors.AffixTable, actors.LootTable, or any other array-of-records a game ...
import { parseCSV } from '@datamoc/mwg/core';
import type { AffixDef, AffixTable } from '@datamoc/mwg/actors';
const csv = `id,trigger,weight,curse,description
blazing,strike,3,,Ignites the victim
wayward,strike,1,true,"Cursed: -3 accuracy"`;
const table: AffixTable = {
entries: parseCSV<AffixDef>(csv, { columns: { weight: 'number', curse: 'boolean' } }),
};
...
DialogueLine
interface
The terse @id/- dialogue-text parsing engine behind both two-d/stage.parseDialogueText and rpg.parseDialogueText - the two-speaker alternation state machine lives here once, in core (already the home of parseCSV, another ...
import { parseDialogueLines, type DialogueLine } from '@datamoc/mwg/core';
const lines: DialogueLine[] = parseDialogueLines(`
@alice Hello.
@bob Hi!
- How are you?
`);
console.log(lines); // [{ text: 'Hello.', speaker: 'alice' }, ...]
EntityId
type
A logical identity for a live object, so events, saves, AI targets and buffs can name a creature or item without holding the object itself - a sprite can be swapped, a save can be reloaded into fresh objects, but the id names the same ...
import { EntityRegistry, type EntityId } from '@datamoc/mwg/core';
interface Monster { name: string }
const monsters = new EntityRegistry<Monster>();
const rat: EntityId = monsters.add({ name: 'rat' });
console.log(monsters.get(rat)?.name); // 'rat'
console.log(monsters.idOf(monsters.get(rat)!)); // same id back - the reverse lookup a
// game passes as `SimulationRuntime`'s or `Scheduler`'s `actorId`
FeedbackClient
class
Small, injectable transport for a game's own feedback endpoint.
import { FeedbackClient } from '@datamoc/mwg/core';
const feedback = new FeedbackClient({ endpoint: 'https://example.com/api/feedback' });
await feedback.submit({ message: 'the boss fight softlocks on floor 5', contact: 'a@b.com' });
FixedStepper
class
Turns the variable frame time Scene.update(dt) receives into fixed simulation steps.
import { FixedStepper } from '@datamoc/mwg/core';
const stepper = new FixedStepper(1 / 60);
const world = { step: (h: number) => console.log('step', h) };
// inside Scene.update(dt), here one 40 ms frame:
const dt = 0.04;
stepper.advance(dt, (h) => world.step(h));
const blend = stepper.alpha; // 0..1 into the next step, for interpolated drawing
FloatSource
interface
The minimal draw surface for the helpers that draw floats only (float, chance, weighted, weightedKey). A rule whose draws are floats-only implements just this - no dead int member that must never be called.
import { Random } from '@datamoc/mwg/core';
import type { FloatSource } from '@datamoc/mwg/core';
// a scripted float-only stream: every draw is data, no integer path exists
const scripted: FloatSource = { float: () => 0.25 };
const hit = Random.chance(0.5, scripted);
Generator
class
The seeded generator class Random's free functions wrap directly, for a game that wants its own independent stream rather than sharing the ambient one.
import { Generator } from '@datamoc/mwg/core';
const rng = new Generator(42); // same seed, same sequence, every time
const roll = rng.int(20);
const saved = rng.getState(); // resumable, for a save file
Handles
interface
Numeric leases for values a plain-data command cannot carry.
import { createHandles } from '@datamoc/mwg/core';
const handles = createHandles<() => number>();
const result = handles.with(() => 7, (id) => {
const fn = handles.get(id);
return fn();
});
console.log(result); // 7
HexCoord
interface
Flat-top hexagon geometry, addressed the same way a square grid is: x the column, y the row, both integers - which is what lets mwg/roguelike's Level (grid logic) and mwg/render's TileMap (pixel positions) each reuse it without ...
import { hexNeighbors, hexDistance, hexLine, hexRange, hexToPixel, pixelToHex } from '@datamoc/mwg/core';
const start = { x: 2, y: 2 };
const neighbours = hexNeighbors(start.x, start.y); // the 6 adjacent cells
const cellsAway = hexDistance(start, { x: 4, y: 1 });
const path = hexLine(start, { x: 4, y: 1 }); // every cell the line crosses
const nearby = hexRange(start, 2); // every cell within 2 hexes
const pixel = hexToPixel(start.x, start.y, 32, 28); // tile width/height in pixels
const backToHex = pixelToHex(pixel.x, pixel.y, 32, 28); // round-trips to { x: 2, y: 2 }
Hook
interface
A named-event registry whose handlers can be removed in bulk by whatever registered them.
import { HookRegistry } from '@datamoc/mwg/core';
const hooks = new HookRegistry<[amount: number]>();
const poisonRing = {};
hooks.on('turnStart', (amount) => console.log('poison ticks for', amount), poisonRing);
hooks.emit('turnStart', 3);
// the ring is removed - every hook it registered comes off in one call
hooks.offSource(poisonRing);
HttpTransport
class
The constructor validation and timeout-wrapped fetch shared by every injectable HTTPS transport here (FeedbackClient, NewsClient, SaveSyncClient, TelemetryClient) - each one is a plain request/response against a game's own ...
import { HttpTransport, type HttpTransportOptions } from '@datamoc/mwg/core';
class ScoreClient extends HttpTransport {
constructor(options: HttpTransportOptions) {
super(options, 'score');
}
}
const client = new ScoreClient({ endpoint: 'https://example.com/scores' });
console.log(client instanceof HttpTransport); // true
Input
namespace
Keyboard input, bound to named actions.
import { Input } from '@datamoc/mwg/core';
Input.bind('confirm', ['Enter', 'Space']);
Input.bind('left', ['ArrowLeft', 'KeyA']);
// in the game loop:
if (Input.justPressed('confirm')) console.log('confirmed');
if (Input.isDown('left')) console.log('moving left');
JavaRandom
class
The 48-bit linear congruential generator behind java.util.Random, bit for bit, so a game ported from the JVM can replay the same seed and get the same dungeon. The algorithm is the one the Java documentation specifies; every method below ...
import { JavaRandom } from '@datamoc/mwg/core';
const random = new JavaRandom(42);
console.log(random.nextInt()); // -1170105035, what `new Random(42).nextInt()` returns
console.log(random.nextInt(100)); // an int in 0..99
LastRun
class
The newest recording, always kept, bounded: what "watch the previous run" reads. One recording deep by design (the newest replaces the older one), stored through the same SaveStorage a SaveSystem slot uses, so it survives a reload and ...
import { LastRun, Recorder } from '@datamoc/mwg/core';
declare const recorder: Recorder;
const last = new LastRun({ namespace: 'my-game' });
last.keep(recorder.toJSON());
const kept = last.load();
console.log(kept ? kept.events.length : 'no run yet');
LoadQueue
class
Runs named, weighted asynchronous work while exposing truthful loading progress.
import { LoadQueue } from '@datamoc/mwg/core';
const queue = new LoadQueue();
queue.add({ id: 'tileset', run: async () => { await loadTileset(); } });
queue.add({ id: 'sounds', weight: 2, run: async (ctx) => {
await loadSounds((fraction) => ctx.report(fraction));
}});
queue.changed.add((snapshot) => console.log(snapshot.status, snapshot.completed));
await queue.start();
...
LockstepClient
class
A client for this project's own reference lockstep server (tools/multiplayer-server.mjs): the transport, identity, and tick-authority piece item 129 asked for, without mwg inventing a full prediction/rollback netcode a real game would ...
import { LockstepClient } from '@datamoc/mwg/core';
const client = new LockstepClient({ url: 'wss://example.com/room/1' });
client.onTick.add(({ tick, inputs }) => console.log('tick', tick, inputs));
client.connect();
// each frame this client has an input for:
client.submitInput({ move: 'left' });
Logger
class
Categories and severity over bare console.log.
import { Logger } from '@datamoc/mwg/core';
const log = new Logger('combat', { level: 'info' }); // debug entries are filtered out
log.info('turn started', { turn: 3 });
log.warn('attack missed unexpectedly');
MersenneTwister
class
MT19937, the Mersenne Twister the reference strategy game's mt_rng wraps, with the same seed/discard bookkeeping its save files use. The engine itself is exactly specified (the paper's constants and the 32-bit seeding routine), so ...
import { MersenneTwister } from '@datamoc/mwg/core';
const rng = new MersenneTwister(5489);
const first = rng.nextUint32(); // 3499211612, the engine's own first value for this seed
rng.discard(10); // skip ahead without consuming the values
const saved = rng.getState(); // { seed, state, index, discard } - restorable
NewsItem
interface
Fetches a game's own news/announcements feed - the inbound counterpart to FeedbackClient, built on the shared HttpTransport like every other client here (a game's own HTTPS endpoint, no backend shipped or assumed).
import { NewsClient, NewsSeenTracker } from '@datamoc/mwg/core';
const news = new NewsClient({ endpoint: 'https://example.com/api/news' });
const seen = new NewsSeenTracker({ namespace: 'my-game' });
const items = await news.fetchItems();
const unread = items.filter((item) => !seen.isSeen(item.id));
for (const item of unread) seen.markSeen(item.id);
Player
class
Re-dispatches recorded events at their recorded frames.
import { Player, Signal, deserializeReplay } from '@datamoc/mwg/core';
declare const saved: string; // written by serializeReplay, see Recorder
const onFrame = new Signal<number>();
const replayed: string[] = [];
const player = new Player(deserializeReplay(saved), (action) => replayed.push(action), onFrame);
onFrame.dispatch(0);
...
PlayerInput
class
A player-scoped view over mwg/core's Input module for same-screen local multiplayer.
import { PlayerInput } from '@datamoc/mwg/core';
const player1 = new PlayerInput('p1', { padIndex: 0 });
const player2 = new PlayerInput('p2', { padIndex: 1 });
player1.bind('confirm', ['Enter']);
player2.bind('confirm', ['Space']);
// each player's "confirm" fires independently, even though both call it "confirm"
if (player1.justPressed('confirm')) console.log('player 1 confirmed');
PlayerStats
class
A lifetime running total, folded in one run at a time - distinct from RunHistory, which keeps individual runs and may drop the oldest ones once its own limit is reached. Deriving a lifetime total by reducing over RunHistory.all() ...
import { PlayerStats } from '@datamoc/mwg/core';
const lifetimeKills = new PlayerStats<number, number>({
namespace: 'my-game',
initial: 0,
combine: (total, killsThisRun) => total + killsThisRun,
});
lifetimeKills.record(12); // this run's own kill count folds into the total
console.log(lifetimeKills.get());
PresentationQueue
class
Plays simulation events one at a time, at whatever pace their presentation actually takes, entirely separate from the commit that already happened: simulation.SimulationRuntime. dispatch (or runScenario) returns its events ...
import { PresentationQueue } from '@datamoc/mwg/core';
type GameEvent = { type: 'move' } | { type: 'damage'; amount: number };
const presentation = new PresentationQueue<GameEvent>({
play: (event) => {
if (event.type === 'move') return 0.2; // a move tween takes 200ms
console.log('damage:', event.amount); // a floating number, shown instantly
},
});
...
Random
namespace
Deterministic random numbers.
import { Random } from '@datamoc/mwg/core';
// a floor generated from a seed always comes out the same
Random.push(12345);
const roll = Random.int(1, 20);
const critical = Random.chance(0.05);
Random.pop();
RandomStreams
class
Named, independent random streams over one base seed: streams.stream('loot') and streams.stream('ai:goblin-3') each get their own MT19937 position, so one consumer drawing more numbers never shifts another's results - the property a ...
import { RandomStreams } from '@datamoc/mwg/core';
const streams = new RandomStreams(12345);
streams.stream('loot').int(100); // one stream
streams.stream('ai:goblin-3').int(6); // an unrelated one, unaffected by the loot draw
const saved = streams.getState(); // every stream's engine state, for a save file
ReactionRule
interface
Custom actions/reactions to changes in any object's state - HP dropping past a threshold, a durability hitting zero, a quest flag flipping true - watched declaratively instead of as a branch cascade of if checks scattered through game ...
import { ReactionTable } from '@datamoc/mwg/core';
const reactions = new ReactionTable<{ hp: number; maxHp: number }>([
{ id: 'critical', when: (s) => s.hp / s.maxHp <= 0.25, action: () => console.log('low hp!') },
{ id: 'shattered', when: (s) => s.hp <= 0, action: () => console.log('broke'), once: true },
]);
reactions.check({ hp: 40, maxHp: 100 }); // ['critical'] - fires once, not every call after
reactions.check({ hp: 38, maxHp: 100 }); // [] - still true, already fired
reactions.check({ hp: 90, maxHp: 100 }); // [] - condition left; 'critical' may fire again later
Recorder
class
Records every action dispatched on a signal, stamped with the current frame.
import { Recorder, Signal, serializeReplay } from '@datamoc/mwg/core';
const onAction = new Signal<string>();
const onFrame = new Signal<number>();
const recorder = new Recorder(onAction, onFrame);
onFrame.dispatch(0);
onAction.dispatch('confirm');
const saved = serializeReplay(recorder.toJSON());
...
Registry
class
A named lookup of values, registered once and read back by name - the shape underneath a catalog of factories, handlers, or scene sections, so a game can grow that catalog by registering another entry instead of adding another branch to a ...
import { Registry } from '@datamoc/mwg/core';
const commands = new Registry<(args: string[]) => void>();
commands.register('heal', (args) => console.log('healing', args[0]));
commands.get('heal')(['fireling']); // "healing fireling"
commands.get('missing'); // throws: no registration named "missing"
RunHistoryEntry
interface
A personal, local record of completed runs - what core.Session (launch count) and core.Achievements (derived unlocks, no per-run record) don't keep, and distinct from SaveSystem.list(), which enumerates *continuable* slots, not runs ...
import { RunHistory } from '@datamoc/mwg/core';
interface RunSummary { score: number; cause: string }
const runs = new RunHistory<RunSummary>({ namespace: 'my-game', limit: 20 });
runs.record({ score: 340, cause: 'slain by a rat' });
const best = runs.ranked((summary) => summary.score, 'desc');
SaveSyncOptions
type
Uploads/downloads SaveSystem.exportSlot payloads to a game's own HTTPS endpoint - the server half of item 124's save export/import, following FeedbackClient's own shape (an injectable transport a game points at its own endpoint) rather ...
import { SaveSyncClient, SaveSystem } from '@datamoc/mwg/core';
const sync = new SaveSyncClient({ endpoint: 'https://example.com/api/saves' });
const saves = new SaveSystem<{ depth: number }>({ namespace: 'my-game', version: 1 });
const payload = saves.exportSlot('slot1');
if (payload) await sync.upload('slot1', payload);
const slots = await sync.list();
const restored = await sync.download('slot1');
SaveSystem
class
Named, versioned save slots over localStorage (an in-memory store where there is none), for plain JSON state. Bump version when the state's shape changes and give migrations the step from each older version: a slot saved by an ...
import { SaveSystem } from '@datamoc/mwg/core';
interface RunState {
depth: number;
gold: number;
inventory: string[];
}
//version 2 added `inventory`: a slot saved at version 1 gets an empty one when it loads
const saves = new SaveSystem<RunState>({
...
Scene
class
One screen of the game: a title, a menu, the dungeon itself.
import { Scene } from '@datamoc/mwg/core';
// a 2D game extends two-d.Scene2D instead, which adds the Pixi display container;
// this base is what a renderer-free game (or a Babylon one) extends directly.
class TitleScreen extends Scene {
create(): void {
// build the screen's own state here
}
}
SceneComponentHost
class
Composes a scene out of named SceneComponents instead of one class accreting every responsibility a scene ends up needing - a factory assembling named sections (map, encounters, UI) at scene-creation time, each a separate module with its ...
import { Scene, SceneComponentHost, type SceneComponent } from '@datamoc/mwg/core';
class MapSection implements SceneComponent<DungeonScene> {
readonly name = 'map';
create(scene: DungeonScene): void { console.log('map ready for', scene.floor); }
update(_scene: DungeonScene, dt: number): void { }
}
class DungeonScene extends Scene {
floor = 1;
...
SceneStack
class
The stack of scenes a Game runs: exactly one updates, all render.
import { SceneStack, Scene } from '@datamoc/mwg/core';
class DungeonScene extends Scene {
create(): void {}
onResume(result: unknown): void {
console.log('the pushed scene reported back', result);
}
}
class PauseMenu extends Scene {
create(): void {}
...
SessionOptions
interface
Counts how many times a game has been launched, persisted across page loads.
import { Session } from '@datamoc/mwg/core';
const session = new Session({ namespace: 'my-game' });
if (session.launches === 5) {
// a native wrapper's own moment to ask for a rating - mwg never prompts itself
}
Settings
class
Persisted player settings: music and sound-effect levels, master mute, zoom preference, and key bindings, read back on the next launch.
import { Settings, effectiveMusicVolume, effectiveSfxVolume } from '@datamoc/mwg/core';
const settings = new Settings({ namespace: 'my-game' });
settings.update({ musicVolume: 0.7, zoom: 2 });
settings.setCustom('hints', true);
const music = { volume: 1 };
const camera = { zoom: 1 };
music.volume = effectiveMusicVolume(settings.current);
camera.zoom = settings.current.zoom;
...
SignalListener
type
A list of listeners that can consume an event.
import { Signal } from '@datamoc/mwg/core';
const onDamage = new Signal<{ amount: number }>();
onDamage.add(({ amount }) => {
console.log(`took ${amount} damage`);
});
onDamage.dispatch({ amount: 5 });
SizeLimitOptions
interface
Cheap, structural checks over inbound data - a save imported from another device or server (SaveSystem.importSlot), a news feed response (NewsClient), an external save format (rpg.decodeMarshal) - run before any of it is parsed or ...
import { checkSize, checkNoControlCharacters, sanitizeInboundText, validateSchema } from '@datamoc/mwg/core';
declare const rawResponseText: string;
// the cheap structural pass, before JSON.parse ever sees the payload
const text = sanitizeInboundText(rawResponseText, { maxBytes: 1_000_000 });
// equivalent to calling checkSize then checkNoControlCharacters by hand
// the deeper pass, once the payload is parsed
const parsed = JSON.parse(text);
...
Spawner
class
A dt-driven timer for escalating, timed spawning - "wave 3 starts at t+45s, spawns 8 of kind A and 2 of kind B over the next 10s" - distinct in shape from roguelike.Scheduler, which orders whose turn it is by energy cost and has no ...
import { Spawner } from '@datamoc/mwg/core';
const spawner = new Spawner<'rat' | 'bat'>({
waves: [
{ delay: 0, entries: [{ kind: 'rat', count: 3 }], duration: 5 },
{ delay: 20, entries: [{ kind: 'rat', count: 5 }, { kind: 'bat', count: 2 }], duration: 10 },
],
onSpawn: (kind) => console.log('spawn', kind),
onWaveStart: (index) => console.log('wave', index, 'starting'),
});
...
StateRegistry
class
Coordinates game-owned save state without knowing its schema.
import { StateRegistry } from '@datamoc/mwg/core';
let inventory = { gold: 10 };
const state = new StateRegistry();
state.register({ id: 'inventory', capture: () => inventory, restore: (saved) => { inventory = saved; } });
state.transaction(() => { inventory.gold -= 5; });
const save = state.snapshot();
StoredValue
class
One JSON value under one storage key: the read-with-fallback / write / remove trio that PlayerStats, RunHistory and NewsSeenTracker each hand-rolled, with slightly different falsy-vs-null edges per copy. The one here treats any ...
import { StoredValue, defaultStorage } from '@datamoc/mwg/core';
const progress = new StoredValue<{ solved: number[] }>(defaultStorage(), 'mygame.progress');
progress.write({ solved: [...progress.read({ solved: [] }).solved, 3] });
SyncGuard
class
Compares the checksum peers compute for each tick. The first checksum seen for a tick is taken as the reference; a later observation of the same tick that disagrees marks the run divergent and is remembered as the first such tick, so a ...
import { SyncGuard, stateChecksum } from '@datamoc/mwg/core';
const localState = { hp: 7 };
const remoteState = { hp: 6 };
const guard = new SyncGuard();
guard.observe(12, stateChecksum(localState)); // true
guard.observe(12, stateChecksum(remoteState)); // false if the two peers disagree
if (guard.divergent) console.log('desync at tick', guard.atTick);
TelemetryClient
class
An injectable transport for automatic, structured analytics events - FeedbackClient's own shape, for a different question. FeedbackClient is a player choosing to write something and send it; this is a game deciding on its own to send ...
import { TelemetryClient } from '@datamoc/mwg/core';
const telemetry = new TelemetryClient({ endpoint: 'https://example.com/api/telemetry' });
// nothing sends until a game's own consent flow grants it
telemetry.setConsent(true);
await telemetry.send({ name: 'level_started', properties: { level: 3 } });
TweeChoice
interface
The Twee-notation parsing engine behind both two-d/stage.importTwee and rpg.importTwee - the passage/link parsing lives here once, in core (already the home of parseCSV, another generic text-format parser other modules build their ...
import { parseTwee, type TweeStory } from '@datamoc/mwg/core';
const story: TweeStory = parseTwee(`:: Start
Pick a door.
[[Left->A]]
[[Right->B]]
:: A
Treasure.
...
Tweener
class
Runs any number of concurrent tweens; a game or a widget drives it from its own update(dt).
import { Tweener, Easing } from '@datamoc/mwg/core';
const tweener = new Tweener();
const sprite = { alpha: 0 };
tweener.tween(0.5, (t) => { sprite.alpha = t; }, Easing.easeOutQuad);
// in the game loop:
tweener.update(1 / 60);
UndoHistoryOptions
interface
A bounded, generic back/forward step through recent turns - a mode a game opts into (useless in a permadeath roguelike, useful elsewhere), agnostic about what "a turn's state" actually contains the same way SaveSystem is agnostic about ...
import { UndoHistory } from '@datamoc/mwg/core';
interface TurnState { x: number; y: number }
const history = new UndoHistory<TurnState>({ limit: 20 });
history.push({ x: 0, y: 0 });
history.push({ x: 1, y: 0 });
if (history.canUndo) {
const previous = history.undo();
...
bisectShareLink
function
The longest prefix of items whose link fits, found by bisection: build makes the payload for a count, and the answer is the largest count that fits (0 is allowed, for a payload with no items). Counts that do not fit are never tried ...
import { bisectShareLink } from '@datamoc/mwg/core';
const notes = Array.from({ length: 500 }, (_, i) => [60 + (i % 12), i]);
const { link, kept } = await bisectShareLink('music', 'game@1.0.0', notes, (items, cut) => ({ cut, items }), {
maxChars: 800,
});
console.log(link.length <= 800, kept < notes.length); // true true
chebyshev
function
Chebyshev ruler distance between two cells: the king-move count, a diagonal costing one step.
import { chebyshev } from '@datamoc/mwg/core';
chebyshev({ x: 0, y: 0 }, { x: 3, y: 4 }); // 4
clamp
function
value restricted to [min, max].
import { clamp } from '@datamoc/mwg/core';
clamp(15, 0, 10); // 10
clamp(-5, 0, 10); // 0
clamp(5, 0, 10); // 5
cloneData
function
structuredClone, with a failure that names what could not be copied and where, instead of the bare DataCloneError a journal or undo checkpoint would otherwise surface far from the code that put a live object there.
import { cloneData } from '@datamoc/mwg/core';
const checkpoint = cloneData({ turn: 3, hp: { king: 12 } }, 'state');
cloneData({ onHit: () => {} }, 'command'); // throws: command.onHit cannot be structured-cloned ...
defaultSettings
function
The values a fresh game starts with: full volume, unmuted, zoom 1, default bindings.
import { defaultSettings } from '@datamoc/mwg/core';
console.log(defaultSettings().zoom); // 1
distance
function
Measures a to b with the named ruler, so a rule authored as data (a table row, a authored profile) can pick its geometry without importing each ruler by name.
import { distance } from '@datamoc/mwg/core';
distance('chebyshev', { x: 0, y: 0 }, { x: 3, y: 4 }); // 4
distance('euclidean', { x: 0, y: 0 }, { x: 3, y: 4 }); // 5
downloadReplayFile
function
Saves a replay file locally: a Blob download, which works from a file:// page with no server and no fetch(). Nothing is read back; the file is the export.
import { downloadReplayFile, exportReplayFile } from '@datamoc/mwg/core';
const json = exportReplayFile([], { framework: '0.26.1', game: 'my-game' });
downloadReplayFile(json, 'run-1.mwgreplay.json');
encodeShareLink
function
Makes the link for an envelope; throws when the link is longer than maxChars, so a caller that must always produce a link uses fitShareLink or bisectShareLink instead.
import { decodeShareLink, encodeShareLink } from '@datamoc/mwg/core';
const link = await encodeShareLink({ type: 'state', id: 'pontifex@1.2.0', payload: { seed: 4242 } }, { base: 'https://example.org/play' });
const read = await decodeShareLink<{ seed: number }>(link);
console.log(read.ok && read.payload.seed); // 4242
euclidean
function
Euclidean ruler distance between two cells.
import { euclidean } from '@datamoc/mwg/core';
euclidean({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
exportReplayFile
function
Wraps a recording in the versioned, self-describing envelope, as a JSON string ready to save as a file. The events are validated and copied, so the exported file cannot drift with a recording that keeps running.
import { exportReplayFile, Recorder, Signal, version } from '@datamoc/mwg';
declare const recorder: Recorder;
const file = exportReplayFile(recorder.toJSON(), {
framework: version,
game: 'my-game',
recordedAt: Date.now(),
});
fitShareLink
function
Makes a link from the first of candidates that fits, listed from the fullest content to the leanest; reductions tells the receiver how many steps were dropped. Throws when even the last does not fit. dropByPriority builds such a list ...
import { dropByPriority, fitShareLink } from '@datamoc/mwg/core';
const loadout = { seed: 7, equipment: ['sword', 'lamp'], decoration: 'banner' };
const ladder = dropByPriority(loadout, ['seed', 'equipment', 'decoration']);
const { link, reductions } = await fitShareLink('state', 'game@1.0.0', ladder, { maxChars: 600 });
console.log(link.length <= 600, reductions); // true 0
importReplayFile
function
Parses and checks a replay file, refusing by name anything that is not this game on this framework: a replay from another build does not fail somewhere mid-playback, it is rejected at the door with what it actually carries.
import { importReplayFile, version } from '@datamoc/mwg';
declare const json: string; // read from a file, see readReplayFile
const file = importReplayFile(json, { framework: version, game: 'my-game' });
console.log(file.events.length, file.seed);
manhattan
function
Manhattan ruler distance between two cells: the orthogonal-step count, no diagonals.
import { manhattan } from '@datamoc/mwg/core';
manhattan({ x: 0, y: 0 }, { x: 3, y: 4 }); // 7
motionDuration
function
The duration a motion should actually take.
import { Tweener, motionDuration, setReducedMotion } from '@datamoc/mwg/core';
const tweener = new Tweener();
const panel = { x: -200, fill: 0 };
//a slide is decorative: with reduced motion it arrives at once. A health bar is meaningful:
//it still fills, only faster
void tweener.tween(motionDuration(0.4), (t) => {
panel.x = -200 + 200 * t;
});
...
parseCSV
function
import { parseCSV } from '@datamoc/mwg/core';
const rows = parseCSV('id,weight\nblazing,3\nchilling,3', { columns: { weight: 'number' } });
console.log(rows); // [{ id: 'blazing', weight: 3 }, { id: 'chilling', weight: 3 }]
parseDialogueLines
function
Parses the terse @id text / - text dialogue format into one DialogueLine per spoken or narrated line, in order. To play it on a scene, hand the same source to parseDialogueText in two-d/stage (or rpg), which wraps these lines ...
import { parseDialogueLines } from '@datamoc/mwg/core';
const lines = parseDialogueLines(`
The bridge creaks.
@mara Careful.
@jon I know.
- Is it holding?
- Barely.
`);
...
parseInbound
function
The whole inbound pass in one call: sanitizeInboundText, then JSON.parse with a reviver that refuses __proto__/constructor/prototype at any depth. Every path in core that reads JSON it did not just produce goes through this ...
import { parseInbound } from '@datamoc/mwg/core';
parseInbound('{"hp": 3}'); // { hp: 3 }
parseInbound('{"__proto__": {"admin": true}}'); // throws: $ contains a forbidden key "__proto__"
parseReplayEvents
function
Validates an already-parsed replay - the same { frame, action } checks deserializeReplay applies, for the callers that hold parsed JSON rather than a string: a replay-file envelope nests its events as an array, and re-stringifying them ...
import { parseReplayEvents } from '@datamoc/mwg/core';
const events = parseReplayEvents([{ frame: 3, action: 'confirm' }]);
parseTwee
function
Parses Twee passages (:: Name, text lines, [[links]]) into a TweeStory: each passage a list of say commands closed by an ask when it has links. To play one on a scene, import the source with importTwee from two-d/stage (or ...
import { parseTwee } from '@datamoc/mwg/core';
const story = parseTwee(`:: Start
You reach the gorge.
[[Build a bridge->Build]]
[[Turn back->Home]]
:: Build
The planks hold.
...
pickReplayFile
function
Opens a file picker for one replay file, resolving null when the player cancels. The file:// import path: no fetch(), no drag-drop wiring to get wrong.
import { importReplayFile, pickReplayFile, readReplayFile, version } from '@datamoc/mwg';
const picked = await pickReplayFile();
if (picked) {
const replay = importReplayFile(await readReplayFile(picked), { framework: version, game: 'my-game' });
}
readReplayFile
function
Reads a chosen file (or any Blob) as text, headless and in the browser alike: the browser half of an import is the picker, this is the rest. Blob.text() needs no DOM, so the same function reads a node:fs buffer's Blob in a tool.
import { importReplayFile, readReplayFile, version } from '@datamoc/mwg';
declare const file: Blob; // from pickReplayFile, or a Node-side Blob
const json = await readReplayFile(file);
const replay = importReplayFile(json, { framework: version, game: 'my-game' });
resumeRunPlayer
function
The playback half of a restored checkpoint: a Player that continues the recording from the checkpoint's frame, skipping the events before it, so the run resumes rather than restarting. The restored state and the resumed playback travel ...
import { resumeRunPlayer, runCheckpoint, Recorder, Signal } from '@datamoc/mwg/core';
declare const recorder: Recorder;
const checkpoint = runCheckpoint(recorder, { hp: 9 });
const onFrame = new Signal<number>();
const player = resumeRunPlayer(checkpoint, (action) => console.log(action), onFrame);
player.stop();
runCheckpoint
function
Pairs a live recording with the state a save already captured, as plain data a SaveSystem slot stores: restoring the slot restores the run, not just the numbers.
import { runCheckpoint, Recorder } from '@datamoc/mwg/core';
declare const recorder: Recorder;
const checkpoint = runCheckpoint(recorder, { depth: 4, hp: 9 });
console.log(checkpoint.frame, checkpoint.state.hp);
scramble
function
XOR-with-a-key text scrambling. This is explicitly **not encryption** and must never be presented to a player or a game's own documentation as one: it stops a save being hand-edited by someone opening it in a text editor, and deliberately ...
import { scramble, unscramble } from '@datamoc/mwg/core';
const scrambled = scramble('{"depth":3}', 'a-per-game-key');
const restored = unscramble(scrambled, 'a-per-game-key'); // '{"depth":3}'
stateChecksum
function
A 32-bit FNV-1a checksum of a state value, stable across machines and across property order.
import { stateChecksum } from '@datamoc/mwg/core';
// same state, whatever order the keys happen to be in
stateChecksum({ hp: 7, x: 2 }) === stateChecksum({ x: 2, hp: 7 }); // true
stateChecksum({ hp: 7 }) === stateChecksum({ hp: 6 }); // false
uncloneablePath
function
Where structuredClone would fail inside value, as a readable path (command.attacker. reactions.rules[0].when), or null when the whole value clones. A function, a symbol, a WeakMap/WeakSet, a Promise or a DOM node all fail; a ...
import { uncloneablePath } from '@datamoc/mwg/core';
uncloneablePath({ target: { id: 'king', onHit: () => {} } }, 'command'); // 'command.target.onHit'
uncloneablePath({ targetId: 'king' }, 'command'); // null
weightedFlood
function
Dijkstra-style weighted flood fill, independent of a particular board or game rule.
import { weightedFlood } from '@datamoc/mwg/core';
const cells = weightedFlood(0, {
key: (cell) => cell,
neighbors: (cell) => cell < 3 ? [cell + 1] : [],
cost: () => 1,
maxCost: 2,
});
console.log(cells.get(2)?.cost); // 2
headless
assetBinary
namespace
Raw asset bytes, cached and progress-reported the same way loader.ts's texture cache is - but renderer-free, so a Babylon-only game (or anything else that just wants an ArrayBuffer) gets the same load/isLoaded/release shape a 2D ...
import { loadBinary, getBinary } from '@datamoc/mwg/assets';
import { parseVox, createVoxModel3D } from '@datamoc/mwg/3d';
await loadBinary(['models/rock.vox']);
const model = parseVox(getBinary('models/rock.vox'));
assetPaths
namespace
Where a game's assets come from, as pure path arithmetic.
import { setBase, isCompiled, paths, resolve } from '@datamoc/mwg/assets/paths';
setBase('assets/'); // dev-server mode; a compiled build ignores this
if (isCompiled()) console.log('every compiled path:', paths());
const url = resolve('tiles.png'); // a data: URI in a compiled build, 'assets/tiles.png' in dev
i18n
Placeholder
interface
Python f-string-style value formatting for catalog messages: {dmg:03d}, {hp:.1%}, {name:>12}. Plain {token} interpolation stays exactly as it was; a format spec after a colon picks a deterministic, locale-independent rendering ...
import { formatSpec, tokenizeMessage, diffPlaceholders } from '@datamoc/mwg/i18n';
formatSpec(7, '03d'); // '007'
formatSpec(0.125, '.1%'); // '12.5%'
formatSpec('hi', '^7'); // ' hi '
tokenizeMessage('Hit for {dmg:03d}!');
// ['Hit for ', { raw: '{dmg:03d}', token: 'dmg', debug: false, conv: undefined, spec: '03d' }, '!']
diffPlaceholders('Hit for {dmg:03d}!', 'Touché pour {dmg} !');
...
catalogCompleteness
function
How complete other is against reference, as a 0-1 fraction of reference's own keys that other also has - built on diffCatalogKeys rather than recomputing the same set difference, so the two never disagree about what "missing" ...
import { catalogCompleteness } from '@datamoc/mwg/i18n';
const en = { locale: 'en', direction: 'ltr' as const, messages: { a: '1', b: '2' } };
const fr = { locale: 'fr', direction: 'ltr' as const, messages: { a: '1' } };
catalogCompleteness(en, fr); // 0.5
catalogUsage
function
Which of a catalog's keys a game's own source actually references, and which just sit there. mwg has no idea how a game's code names a message key (a string literal, a generated constant, a SemanticMessage.type) - referencedKeys is ...
import { catalogUsage } from '@datamoc/mwg/i18n';
const catalog = {
locale: 'en', direction: 'ltr' as const,
messages: { greeting: 'Hi', unused: 'never shown' },
};
catalogUsage(catalog, ['greeting']); // { totalKeys: 2, usedKeys: 1, unusedKeys: ['unused'] }
createCatalogFormatter
function
The reference MessageFormatter: looks up ${type}.${channel} in the active catalog (setBase/setActive, src/i18n/index.ts), falling back to ${type} alone for a message that reads the same on every channel, then formats through ...
import { setBase, createCatalogFormatter, type SemanticMessage } from '@datamoc/mwg/i18n';
setBase({
locale: 'en',
direction: 'ltr',
messages: {
'combat.damage.log': 'The {target} takes {amount} damage.',
'combat.damage.compact': '-{amount} HP',
},
});
...
diffCatalogKeys
function
Compares two catalogs' key sets - typically a base language against a translation, so a missing or stray key is caught before a player sees a raw key fall through t()'s own fallback, rather than after.
import { diffCatalogKeys } from '@datamoc/mwg/i18n';
const en = { locale: 'en', direction: 'ltr' as const, messages: { greeting: 'Hello', farewell: 'Bye' } };
const fr = { locale: 'fr', direction: 'ltr' as const, messages: { greeting: 'Bonjour', extra: 'oops' } };
console.log(diffCatalogKeys(en, fr)); // { missing: ['farewell'], extra: ['extra'] }
findSimilarMessages
function
Every pair of keys in catalog whose message text is at least minSimilarity alike (default 0.8), surfaced for a human to decide whether they are genuinely the same message translated three separate times - mwg finds the candidates, ...
import { findSimilarMessages } from '@datamoc/mwg/i18n';
const catalog = {
locale: 'en', direction: 'ltr' as const,
messages: { 'confirm.quit': 'Are you sure?', 'confirm.delete': 'Are you sure?' },
};
findSimilarMessages(catalog); // [{ a: 'confirm.quit', b: 'confirm.delete', distance: 0, similarity: 1 }]
formatNumber
function
Locale-aware number/date/list formatting, for a catalog message that needs more than {token} substitution can give it - "1,234" vs "1 234", "3 items" vs "3 éléments" joined with "and"/"et". A game calls these while building a ...
import { setActive, t, formatNumber, formatDate, formatList } from '@datamoc/mwg/i18n';
setActive({ locale: 'fr-FR', direction: 'ltr', messages: { found: 'Vous trouvez {amount}.' } });
console.log(formatNumber(1234.5)); // '1 234,5'
console.log(formatDate(new Date(2024, 0, 15))); // '15/01/2024'
console.log(t('found', { amount: formatList(['une épée', 'un bouclier']) })); // '... une épée et un bouclier.'
levenshteinDistance
function
The classic edit distance: how many single-character insertions, deletions or substitutions turn a into b. The primitive findSimilarMessages is built on, exposed on its own since "how different are these two strings" is useful ...
import { levenshteinDistance } from '@datamoc/mwg/i18n';
levenshteinDistance('kitten', 'sitting'); // 3
levenshteinDistance('same', 'same'); // 0
mergeCatalogKeys
function
Drops mergedKey from catalog, keeping survivingKey (and its current text) exactly as it was - the mechanical half of collapsing two keys a human decided are the same message. Apply this to every language's own catalog to merge ...
import { mergeCatalogKeys } from '@datamoc/mwg/i18n';
const catalog = {
locale: 'en', direction: 'ltr' as const,
messages: { 'confirm.quit': 'Are you sure?', 'confirm.delete': 'Are you sure?' },
};
const merged = mergeCatalogKeys(catalog, 'confirm.quit', 'confirm.delete');
console.log(Object.keys(merged.messages)); // ['confirm.quit']
messageText
function
A catalog message reduced to plain, comparable text: a string as-is, a PluralForms reduced to its other branch (or the first form present, the same fallback resolvePlural itself uses with no count), and a FluentMessage formatted ...
import { messageText } from '@datamoc/mwg/i18n';
messageText('Hello'); // 'Hello'
messageText({ one: '1 item', other: '{count} items' }); // '{count} items'
nonBreakingUnit
function
joins a value and its unit with whatever space French/German typography requires to keep them from breaking across a line - the unit's own text ("pièces d'or", "points de vie", "kg") is always the game's own vocabulary; this only picks the ...
import { nonBreakingUnit } from '@datamoc/mwg/i18n';
console.log(nonBreakingUnit(12, 'pièces d\'or', 'fr')); // '12 pièces d\'or'
console.log(nonBreakingUnit(5, 'kg', 'de')); // '5 kg'
console.log(nonBreakingUnit(3, 'gold', 'en')); // '3 gold'
parsePo
function
Parses the useful, dependency-free subset of a gettext .po file into an i18n Catalog: msgid/msgstr, msgctxt, msgid_plural with msgstr[N], and the header entry, which is read and dropped. Comments are ignored, and so are ...
import { parsePo, setBase, setActive, t } from '@datamoc/mwg/i18n';
const po = [
'msgid "greeting"',
'msgstr "Bonjour, {name} !"',
'',
'msgid "apple"',
'msgid_plural "{n} apples"',
'msgstr[0] "une pomme"',
'msgstr[1] "{n} pommes"',
...
parseSoundMarkers
function
Splits inline sound markers from display text and records their visible positions.
import { parseSoundMarkers } from '@datamoc/mwg/i18n';
parseSoundMarkers('Strike! {sound:hit.wav}');
// { text: 'Strike! ', cues: [{ path: 'hit.wav', index: 8 }] }
pluralFormCoverage
function
How many of a catalog's plural-form messages define each CLDR category - 'few'/'many' sitting at 0 while 'other' covers every key is exactly the gap a language needing those categories (Polish, Arabic) would otherwise only discover ...
import { pluralFormCoverage } from '@datamoc/mwg/i18n';
const catalog = {
locale: 'en', direction: 'ltr' as const,
messages: { items: { one: '{count} item', other: '{count} items' } },
};
pluralFormCoverage(catalog); // { pluralKeys: 1, formsPresent: { one: 1, other: 1 } }
stripSoundMarkers
function
Returns only the player-facing text, removing inline sound markers.
import { stripSoundMarkers } from '@datamoc/mwg/i18n';
stripSoundMarkers('Strike! {sound:hit.wav}'); // 'Strike! '
tRaw
function
Resolves a message by key, interpolating {token} placeholders from params, without any display-text decoration: no typographic spacing, no right-to-left isolate wrapping. This is the resolver for catalog values that are not shown to ...
import { setBase, tRaw } from '@datamoc/mwg/i18n';
setBase({ locale: 'fr', direction: 'ltr', messages: { 'ui.click.audio': 'sounds/hit!.wav' } });
tRaw('ui.click.audio'); // 'sounds/hit!.wav', with no extra spacing before the '!'
validateCatalog
function
Structural problems inside one catalog, independent of any other: an empty string, or a plural form with no other branch - Intl.PluralRules guarantees other is always a valid category for every locale, so a form set lacking it will ...
import { validateCatalog } from '@datamoc/mwg/i18n';
const catalog = {
locale: 'en', direction: 'ltr' as const,
messages: { empty: '', broken: { few: 'a few' } },
};
console.log(validateCatalog(catalog).map((issue) => issue.key)); // ['empty', 'broken']
validateMessageAudio
function
Structural problems in a catalog's sound cues: an <type>.audio entry holding an empty string (the formatter already reads that as "no sound", so it is dead weight), or holding plural forms/a select message instead of a plain path. A ...
import { validateMessageAudio } from '@datamoc/mwg/i18n';
const catalog = {
locale: 'en', direction: 'ltr' as const,
messages: { 'hit.audio': '', 'loot.audio': { one: 'a.wav', other: 'b.wav' } },
};
console.log(validateMessageAudio(catalog).map((issue) => issue.key)); // ['hit.audio', 'loot.audio']
music
MUSIC_GENERATOR_VERSION
const
Bump when the composer's output for a given scene changes, so a save can tell its music is no longer reproducible.
import { MUSIC_GENERATOR_VERSION } from '@datamoc/mwg/music';
const saved = { scene: { id: 'harbour', seed: 7 }, generator: MUSIC_GENERATOR_VERSION };
console.log(saved.generator === MUSIC_GENERATOR_VERSION); // true while the same music comes out
MUSIC_MOODS
const
Every mood a scene may ask for, for a settings list or a validator.
import { MUSIC_MOODS } from '@datamoc/mwg/music';
console.log(MUSIC_MOODS.includes('tense')); // true
composeMusic
function
Composes a piece from a scene: the same scene always gives the same notes. A melody walks the mood's scale over a four-bar chord progression (its first two bars come back in the third and fourth with a changed ending), a bass holds the ...
import { composeMusic } from '@datamoc/mwg/music';
import { renderMidiToBuffer } from '@datamoc/mwg/audio';
const music = composeMusic({ id: 'harbour', seed: 7, mood: 'calm', tension: 0.2, key: 2, bars: 8, voices: 3 });
console.log(music.barSeconds, music.polyphony); // 3 or fewer notes at once
const audio = renderMidiToBuffer(music.file);
console.log(audio.duration > 0);
composeStates
function
One piece per game state, all with the same key, tempo and length in bars, so they can be swapped at a bar line without a jump: AdaptiveMusic.load(name, stems, { barSeconds }) per state, then switchVersion(name, fade, 'bar'). Each ...
import { composeStates } from '@datamoc/mwg/music';
const states = composeStates(
{ id: 'forest', seed: 3, mood: 'calm', tension: 0.2, key: 7, bars: 8, voices: 4 },
{ exploration: {}, combat: { mood: 'tense', tension: 0.9 }, victory: { mood: 'triumphant', tension: 0.4 } },
);
console.log(states.combat.barSeconds === states.exploration.barSeconds); // true
maxPolyphony
function
The most notes sounding at once in a file, counted over every channel. The contract that makes a piece writable for a chip with a fixed number of voices.
import { composeMusic, maxPolyphony } from '@datamoc/mwg/music';
const music = composeMusic({ id: 'a', voices: 3 });
console.log(maxPolyphony(music.file) <= 3); // true
normalizeScene
function
Makes a scene safe to compose from whatever a save, a share link or a settings screen hands over: unknown moods become calm, every number is clamped and rounded to its range.
import { normalizeScene } from '@datamoc/mwg/music';
console.log(normalizeScene({ id: 'cave', mood: 'tense', tension: 4, voices: 99 })); // tension 1, voices 8
mwl
DEFAULT_PACKAGE_LIMITS
const
The limits applied when a caller names none. A game raises them by spreading over this object.
import { DEFAULT_PACKAGE_LIMITS, decodePackage } from '@datamoc/mwg/mwl';
const roomy = { ...DEFAULT_PACKAGE_LIMITS, maxEntries: 4096 };
await decodePackage(new Uint8Array(8), roomy).catch(() => console.log(roomy.maxEntries)); // 4096
KNOWN_PACKAGE_CAPABILITIES
const
The capabilities mwg itself knows how to grant; a package may name others, a policy decides.
import { KNOWN_PACKAGE_CAPABILITIES } from '@datamoc/mwg/mwl';
console.log(KNOWN_PACKAGE_CAPABILITIES); // ['audio', 'script']
MWL_DEFAULT_CARRYOVER_PERCENTAGE
const
Wesnoth's default: the share of a side's gold that survives into the next scenario, unless [endlevel] carryover_percentage says otherwise. Its engine carries the winner's gold at 80%, which is the number this mirrors rather than one ...
import { MWL_DEFAULT_CARRYOVER_PERCENTAGE } from '@datamoc/mwg/mwl';
console.log(MWL_DEFAULT_CARRYOVER_PERCENTAGE); // 80
MWL_SCHEMA_01
const
Published MWL schema identifiers understood by the compiler and migration tooling.
import { MWL_SCHEMA_01, MWL_SCHEMA_10 } from '@datamoc/mwg/mwl';
console.log(MWL_SCHEMA_01, MWL_SCHEMA_10); // '0.1', '1.0'
MwlColumnValueType
type
The TypeScript type one coerced table cell holds, mirroring coerceTableValue exactly: string stays a string, number/boolean coerce to their namesakes, list splits into a string array, and map splits into a string-to-string ...
import type { MwlColumnValueType } from '@datamoc/mwg/mwl';
const cost: MwlColumnValueType<'number'> = 3;
const kinds: MwlColumnValueType<'list'> = ['sharp', 'shiny'];
console.log(cost, kinds.length); // 3 2
MwlRuntime
class
Runtime for compiled MWL content. It never parses source text during play.
import { compile, MwlRuntime } from '@datamoc/mwg/mwl';
const runtime = new MwlRuntime(compile("[{ tag: 'game', schema: 0.1 }]"), {
resolveMap: () => 'Gg,Gg\nGg,Gg',
});
console.log(runtime.world.turn); // 1
MwlSyntaxError
class
The error a malformed MWL document throws, carrying the diagnostic that describes it.
import { parse, MwlSyntaxError } from '@datamoc/mwg/mwl';
try {
parse('[{ tag: "game" }]', 'broken.mwl');
} catch (error) {
if (error instanceof MwlSyntaxError) console.log(error.diagnostic.code);
}
MwlTableReference
type
One declarative cross-table reference: a column of one [table] whose every cell must resolve: against a column of another [table], against an authored node-id namespace (references: { node: 'monster' } checks every cell names an ...
import { compile, validateCatalog } from '@datamoc/mwg/mwl';
const game = compile(
"[{ tag: 'table', id: 'items', columns: 'id:string', children: [{ tag: 'row', id: 'sword' }] }, { tag: 'table', id: 'recipes', columns: 'ingredient:string', children: [{ tag: 'row', ingredient: 'sword' }] }]",
);
const declarations = [{ table: 'recipes', column: 'ingredient', references: { table: 'items', column: 'id' } }];
console.log(validateCatalog(game, { tableReferences: declarations })); // []
MwlTypedRow
type
A table's rows with each declared column's name and coerced value type.
import { typedRows } from '@datamoc/mwg/mwl';
import type { MwlTableColumn } from '@datamoc/mwg/mwl';
const columns = [
{ name: 'kind', type: 'string' },
{ name: 'cost', type: 'number' },
] as const satisfies readonly MwlTableColumn[];
const rows = typedRows({ columns, rows: [{ kind: 'torch', cost: 3 }] });
console.log(rows[0]?.cost); // 3
PACKAGE_ASSET_KINDS
const
The kind values an asset tag accepts.
import { PACKAGE_ASSET_KINDS } from '@datamoc/mwg/mwl';
console.log(PACKAGE_ASSET_KINDS.includes('synth-patch')); // true
PACKAGE_MEDIA_KINDS
const
Kinds that are somebody's creative work: license and source are mandatory for these.
import { PACKAGE_MEDIA_KINDS } from '@datamoc/mwg/mwl';
console.log(PACKAGE_MEDIA_KINDS.includes('data')); // false
PACKAGE_SCRIPT_ACTIONS
const
The event vocabulary a package script may do. It is the data-only subset of the MWL event actions plus play and give; hook (game-author code), endlevel, win, lose, spawn, kill and anything else that decides the outcome of ...
import { PACKAGE_SCRIPT_ACTIONS } from '@datamoc/mwg/mwl';
console.log(PACKAGE_SCRIPT_ACTIONS.includes('hook')); // false
PACKAGE_TAGS
const
The MWL tags a package document may hold, and the only ones. Standard game tags (unit_type, event, ...) are refused in a package: a character expands into them, and keeping the two apart means nothing in a shared file can reach the ...
import { PACKAGE_TAGS } from '@datamoc/mwg/mwl';
console.log(PACKAGE_TAGS.includes('character')); // true
PackageConflictError
class
A second package with the same id and version, or one whose dependency is not merged.
import { PackageConflictError } from '@datamoc/mwg/mwl';
console.log(new PackageConflictError('already merged').name); // 'PackageConflictError'
PackageError
class
The base of every error a package can raise, so a caller can refuse them all with one test.
import { PackageError } from '@datamoc/mwg/mwl';
console.log(new PackageError('refused') instanceof Error); // true
PackageFormatError
class
The file is not a well-formed package: bad magic, truncated, malformed directory or media.
import { decodePackage, PackageFormatError } from '@datamoc/mwg/mwl';
await decodePackage(new Uint8Array(8)).catch((error) => console.log(error instanceof PackageFormatError)); // true
PackageIntegrityError
class
An entry does not match the digest the directory declares for it.
import { PackageIntegrityError } from '@datamoc/mwg/mwl';
console.log(new PackageIntegrityError('sha256 mismatch').name); // 'PackageIntegrityError'
PackageLimitError
class
The package exceeds a declared limit (size, entry count, image dimensions, audio length).
import { PackageLimitError } from '@datamoc/mwg/mwl';
console.log(new PackageLimitError('too many entries').name); // 'PackageLimitError'
PackagePathError
class
A path escapes the package (absolute, .., a drive, a backslash) or appears twice.
import { checkPackagePath, PackagePathError } from '@datamoc/mwg/mwl';
try {
checkPackagePath('../escape.txt');
} catch (error) {
console.log(error instanceof PackagePathError); // true
}
attributeTypeDescription
function
How a diagnostic describes a declared attribute type, naming the alternatives when the type is a closed list rather than one of the shared value types.
import { attributeTypeDescription } from '@datamoc/mwg/mwl';
console.log(attributeTypeDescription(['literal', 'number'])); // 'one of literal, number'
campaignChain
function
Points a [campaign] at simulation.Campaign: the order comes out of the content, the playing stays the game's.
import { campaignChain } from '@datamoc/mwg/mwl';
import { Campaign } from '@datamoc/mwg/simulation';
const definition = {
id: 'prologue',
firstScenario: 'opening',
scenarios: [{ id: 'opening', nextScenario: 'siege' }, { id: 'siege' }],
};
const chain = campaignChain(definition, {
...
checkConditionSyntax
function
Checks that a condition is well-formed and within the step bounds, without evaluating it: the variables are not known yet when a shared package is verified. Returns the identifiers it reads.
import { checkConditionSyntax } from '@datamoc/mwg/mwl';
console.log(checkConditionSyntax('self.hp <= self.maxHp / 4')); // ['self.hp', 'self.maxHp']
checkPackagePath
function
Validates a path that will live inside a package and returns it unchanged.
import { checkPackagePath } from '@datamoc/mwg/mwl';
console.log(checkPackagePath('media/portrait.png')); // 'media/portrait.png'
checkPackagePolicy
function
Evaluates a package against what a game accepts and returns readable refusals; an empty list means accepted. Nothing is executed or fetched: the decision rests on the declared requests.
import { checkPackagePolicy, parsePackageDocument } from '@datamoc/mwg/mwl';
const document = parsePackageDocument(
"[{ tag: 'package', id: 'marta', version: '1.0.0', requires: { capabilities: ['script'] } }]",
);
console.log(checkPackagePolicy(document, { allow: ['audio'] })); // ['asks for the "script" capability, which this game does not allow']
coerceTableValue
function
Coerces one table cell from text using the column's type, accepting yes/no for booleans.
import { coerceTableValue } from '@datamoc/mwg/mwl';
console.log(coerceTableValue('3', 'number', 'cost')); // 3
collectHookReferences
function
Every hook a compiled game references, deduplicated by type:name and ordered so the output is stable.
import { collectHookReferences, type MwlCompiledGame } from '@datamoc/mwg/mwl';
declare const game: MwlCompiledGame;
console.log(collectHookReferences(game).map((reference) => `${reference.type}:${reference.name}`));
compareVersions
function
Orders two x.y.z versions: negative when a is older, positive when newer. It decides which of two coexisting versions of a package owns the plain packageId:id name.
import { compareVersions } from '@datamoc/mwg/mwl';
console.log(compareVersions('1.2.0', '1.10.0') < 0); // true
compile
function
Preprocesses, parses and validates MWL source into a compiled game.
import { compile } from '@datamoc/mwg/mwl';
const game = compile("[{ tag: 'game', schema: 0.1 }]");
console.log(game.schema); // '0.1'
compileAndEmitSources
function
Compiles the same source set twice and fails if any generated artifact differs.
import { compileAndEmitSources } from '@datamoc/mwg/mwl';
const artifacts = compileAndEmitSources([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
console.log(artifacts.length); // 3
compileNodes
function
Compile an already-parsed MWL node tree. A game that builds its nodes from another format (the Wesnoth port converts its own parsed nodes this way) can validate and compile through the same schema without going back to text.
import { compileNodes, parse } from '@datamoc/mwg/mwl';
const game = compileNodes(parse("[{ tag: 'game', schema: 0.1 }]"));
console.log(game.schema); // '0.1'
compilePackages
function
Compiles a content set: each character expands into a unit_type named packageId:id, and the result is an ordinary compiled game, with the packages' _() strings in messages and the packages' media paths in assets. A set with no ...
import { compilePackages, encodePackage, loadPackage, mergePackage } from '@datamoc/mwg/mwl';
const source = "[{ tag: 'package', id: 'marta', version: '1.0.0' }, { tag: 'character', id: 'marta', hitpoints: 28 }]";
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode(source) }]);
console.log(compilePackages(mergePackage(await loadPackage(bytes))).roots[0]?.attributes.id); // 'marta:marta'
compileSources
function
Compiles a deterministic set of content files as one catalog.
import { compileSources } from '@datamoc/mwg/mwl';
const game = compileSources([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
console.log(game.roots.length); // 1
composeEffects
function
Resolve a declarative MWL effect list using the same rule as StatBlock.
import { composeEffects, type MwlEffectDefinition } from '@datamoc/mwg/mwl';
declare const effects: readonly MwlEffectDefinition[];
console.log(composeEffects(10, effects, { level: 2 }));
contentCatalog
function
The reusable content a game can read: campaigns, items, monsters, statuses, loot, turn clocks, AI profiles, moves, type matchups, evolutions and tables, all as plain data.
import { compile, contentCatalog } from '@datamoc/mwg/mwl';
console.log(contentCatalog(compile("[{ tag: 'game', schema: 0.1 }]")).campaigns); // []
contentReport
function
Summarise a compiled catalog for parity checks and CI output.
import { compile, contentReport } from '@datamoc/mwg/mwl';
console.log(contentReport(compile("[{ tag: 'game', schema: 0.1 }]")).tags);
createExpressionScriptHost
function
Default host for MWL formulas. It never executes statements or calls code.
import { createExpressionScriptHost } from '@datamoc/mwg/mwl';
const host = createExpressionScriptHost();
console.log(host.evaluate('level * 2', { level: 3 })); // 6
host.dispose();
createWorld
function
A fresh, empty world: no sides, units, maps or variables, at turn 1 and playing.
import { createWorld } from '@datamoc/mwg/mwl';
const world = createWorld();
console.log(world.turn, world.status); // 1 'playing'
decodePackage
function
Reads a .mwp. Every entry is inflated within its declared size and matched to its SHA-256 before anything is returned; any problem throws a PackageError subclass and no partial content escapes.
import { decodePackage, encodePackage } from '@datamoc/mwg/mwl';
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode('[]') }]);
const decoded = await decodePackage(bytes, { maxEntries: 8 });
console.log(decoded.files.map((file) => file.path)); // ['content.mwl']
decodeSave
function
Restores a world from a snapshot, applying migrations up to options.version.
import { createWorld, decodeSave, encodeSave } from '@datamoc/mwg/mwl';
const snapshot = encodeSave(createWorld(), { version: 1 });
console.log(decodeSave(snapshot, { version: 1 }).turn); // 1
decodeSaveEnvelope
function
Restores the complete save envelope, including declared game-owned hook state.
import { createWorld, decodeSaveEnvelope, encodeSave } from '@datamoc/mwg/mwl';
const save = encodeSave(createWorld(), { version: 1 }, { 'item-effect:demo': { charges: 1 } });
console.log(decodeSaveEnvelope(save, { version: 1 }).hookState?.['item-effect:demo']);
detectRomImage
function
Decides whether bytes are an instrument ROM image, which a package must never carry. The answer is a registered digest, a .rom extension, or an exact ROM size with no recognised format signature and no text in it. null means nothing ...
import { detectRomImage } from '@datamoc/mwg/mwl';
const refusal = await detectRomImage(new Uint8Array(65536).fill(0xa5), 'pcm.bin');
console.log(refusal?.reason); // 'size'
effectToModifier
function
Converts one MWL effect into a StatBlock modifier, resolving its value expression.
import { effectToModifier, type MwlEffectDefinition } from '@datamoc/mwg/mwl';
declare const effect: MwlEffectDefinition;
console.log(effectToModifier(effect, { level: 2 }));
emitArtifacts
function
Emits the standard MWL outputs plus game-owned artifacts in stable name order.
import { compile, emitArtifacts } from '@datamoc/mwg/mwl';
const artifacts = emitArtifacts(compile("[{ tag: 'game', schema: 0.1 }]"));
console.log(artifacts.map((artifact) => artifact.name)); // ['game-data.ts', 'i18n.json', 'assets.json']
emitHooksDeclaration
function
Emit the declaration a hooks module must satisfy, so a project typechecks its hooks against the exact references its content uses.
import { emitHooksDeclaration, parseHookReference } from '@datamoc/mwg/mwl';
const reference = parseHookReference('predicate:holds');
console.log(emitHooksDeclaration(reference ? [reference] : []).includes("'predicate:holds'"));
emitModule
function
Emits the compiled game as an ES module that assigns it to variable.
import { compile, emitModule } from '@datamoc/mwg/mwl';
console.log(emitModule(compile("[{ tag: 'game', schema: 0.1 }]")).startsWith('export const gameData')); // true
emitTableTypes
function
Emits a TypeScript module with one named row interface and one typed row accessor per table, for a game's own build script to write and import. The declarations describe the same values contentCatalog returns - column type for column ...
import { compile, contentCatalog, emitTableTypes } from '@datamoc/mwg/mwl';
const catalog = contentCatalog(
compile(
"[{ tag: 'table', id: 'roster', columns: 'kind:string', children: [{ tag: 'row', kind: 'goblin' }] }]",
),
);
console.log(emitTableTypes(catalog.tables).includes('export interface RosterRow')); // true
encodePackage
function
Packs files into one .mwp. It refuses what decodePackage would refuse, so a package that was written can be read back under the same limits.
import { decodePackage, encodePackage } from '@datamoc/mwg/mwl';
const text = new TextEncoder().encode('[]');
const bytes = await encodePackage([{ path: 'content.mwl', data: text }]);
console.log((await decodePackage(bytes)).main); // 'content.mwl'
encodeSave
function
Serialises a world to a versioned JSON snapshot.
import { createWorld, encodeSave } from '@datamoc/mwg/mwl';
const snapshot = encodeSave(createWorld(), { version: 1 });
console.log(typeof snapshot); // 'string'
endLevelCarryover
function
Works out what a finished scenario hands on: bonus plus carryover_percentage of the side's gold, the units of that side still standing, and where the campaign goes next.
import { endLevelCarryover, carryoverIntoScenario } from '@datamoc/mwg/mwl';
const world = {
variables: {},
gold: { '1': 200 },
sides: { '1': { gold: 0, income: 2, controller: 'human' } },
units: { hero: { hp: 30, x: 1, y: 1, alive: true, side: '1' } },
maps: {},
turn: 1,
status: 'playing' as const,
...
evaluateCondition
function
Evaluate the side-effect-free condition subset allowed in MWL content.
import { evaluateCondition } from '@datamoc/mwg/mwl';
console.log(evaluateCondition('gold >= 20', { gold: 30 })); // true
evaluateExpression
function
Evaluates an expression against a numeric context. A string is parsed first, so callers can pass content text straight through; a missing or non-numeric variable is an error, not zero.
import { evaluateExpression } from '@datamoc/mwg/mwl';
console.log(evaluateExpression('level * 2', { level: 3 })); // 6
execute
function
Applies one already-validated MwlCommand to a world, without a runtime around it.
import { createWorld, execute } from '@datamoc/mwg/mwl';
const world = createWorld();
execute(world, { name: 'set_variable', target: 'gold', value: 50 });
console.log(world.variables.gold); // 50
extractCatalog
function
Extract a translation catalog compatible with mwg/i18n from a compiled game. MWL keeps the source text as the message id, so the base-language catalog maps each id to itself and translators fill in the other locales.
import { compile, extractCatalog } from '@datamoc/mwg/mwl';
const game = compile("[{ tag: 'game', schema: 0.1 }]");
console.log(extractCatalog(game, { locale: 'fr' }).locale); // 'fr'
generateCredits
function
The credits text for the packages present, in the format of the CREDITS.txt the asset step writes: one block per package and per asset, each with its licence and, when known, its source.
import { generateCredits, parsePackageDocument } from '@datamoc/mwg/mwl';
const document = parsePackageDocument(
"[{ tag: 'package', id: 'marta', version: '1.0.0', author: 'Ada', license: 'CC-BY-4.0' }]",
);
console.log(generateCredits([document])); // 'marta 1.0.0 by Ada\nLicense: CC-BY-4.0\n'
hookTypes
const
The hook kinds MWL content may reference, in the order the documentation lists them.
import { hookTypes } from '@datamoc/mwg/mwl';
console.log(hookTypes.includes('predicate')); // true
inventoryItem
function
Wraps a converted item as an inventory stack of quantity.
import { inventoryItem, itemDefinition, type MwlItemDefinition } from '@datamoc/mwg/mwl';
declare const item: MwlItemDefinition;
const stack = inventoryItem(itemDefinition(item), 3);
console.log(stack.quantity); // 3
isGettext
function
True when a raw attribute value carries the gettext marker _("...").
import { isGettext } from '@datamoc/mwg/mwl';
console.log(isGettext('_("Hello")')); // true
console.log(isGettext('Hello')); // false
isMwlId
function
The rule behind the id value type: an identifier, allowed (unlike ref) to carry the [...] index brackets a WML variable path uses, since [set_variable] name= and every reader that takes that name type it as id. Exported so ...
import { isMwlId } from '@datamoc/mwg/mwl';
console.log(isMwlId('party[0].name')); // true - a variable path is an `id`
console.log(isMwlId('0bad')); // false - it does not start like an identifier
itemDefinition
function
Converts MWL's data shape into the actor layer without importing game rules.
import { itemDefinition, type MwlItemDefinition } from '@datamoc/mwg/mwl';
declare const item: MwlItemDefinition;
const actorItem = itemDefinition(item, { level: 2 });
console.log(actorItem.id);
loadContent
function
Compile content and return a stable, machine-readable load report instead of throwing.
import { loadContent } from '@datamoc/mwg/mwl';
const report = loadContent([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
console.log(report.game?.schema); // '0.1'
loadPackage
function
Decodes a .mwp (digests, limits, paths) and reads its main document. Nothing in the package is executed, fetched or compiled: this is the whole install step.
import { encodePackage, loadPackage } from '@datamoc/mwg/mwl';
const source = "[{ tag: 'package', id: 'marta', version: '1.0.0' }]";
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode(source) }]);
console.log((await loadPackage(bytes)).document.meta.id); // 'marta'
mergePackage
function
Adds a package to a content set (a new set; the old one is untouched). Dependencies named by import must already be in the set. Two versions of one package may coexist: the newest owns the plain packageId:id names, every version ...
import { encodePackage, loadPackage, mergePackage, packageContent } from '@datamoc/mwg/mwl';
const source = "[{ tag: 'package', id: 'marta', version: '1.0.0' }, { tag: 'sound_effect', id: 'ouch', chip: 'sn76489' }]";
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode(source) }]);
const set = mergePackage(await loadPackage(bytes));
console.log(Object.keys(packageContent(set).soundEffects)); // ['marta:ouch']
nodesToObjects
function
Converts compiled nodes into plain objects using the same schema as compile. Attributes become fields; numbers and booleans are converted by their declared types. Children are grouped by tag in source order. A tag with `cardinality.max: ...
import { compile, nodesToObjects } from '@datamoc/mwg/mwl';
import type { MwlTagSchema } from '@datamoc/mwg/mwl';
const schemas = {
bridge: { attributes: { budget: 'number' }, children: ['anchor'] },
anchor: { attributes: { x: 'number', fixed: 'boolean' }, children: [] },
} satisfies Record<string, MwlTagSchema>;
const game = compile("[{ tag: 'bridge', budget: 120, children: [{ tag: 'anchor', x: 4, fixed: true }] }]", { schemas });
const result = nodesToObjects(game.roots, schemas);
if (result.diagnostics.length) throw new Error(JSON.stringify(result.diagnostics));
console.log(result.value[0].budget); // 120 as a number
packageContent
function
The namespaced view of a set: every id becomes packageId:id, and every reference written without a prefix is resolved to the same package first. A reference that already has a prefix points at another package and is left alone. Each item ...
import { encodePackage, loadPackage, mergePackage, packageContent } from '@datamoc/mwg/mwl';
const source = "[{ tag: 'package', id: 'marta', version: '1.0.0' }, { tag: 'music', id: 'theme', src: 'song' }]";
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode(source) }]);
console.log(packageContent(mergePackage(await loadPackage(bytes))).music['marta:theme']?.src); // 'marta:song'
packageRef
function
The exact identity a save game records: id@version#sha256.
import { encodePackage, loadPackage, packageRef } from '@datamoc/mwg/mwl';
const source = "[{ tag: 'package', id: 'marta', version: '1.0.0' }]";
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode(source) }]);
console.log(packageRef(await loadPackage(bytes)).startsWith('marta@1.0.0#')); // true
parseExpression
function
Parses the small arithmetic grammar MWL effects use into an expression tree.
import { parseExpression } from '@datamoc/mwg/mwl';
console.log(parseExpression('level * 2 + 1'));
parseHookReference
function
Parse a type:name hook reference, or null when it does not match.
import { parseHookReference } from '@datamoc/mwg/mwl';
console.log(parseHookReference('predicate:holds')); // { type: 'predicate', name: 'holds' }
console.log(parseHookReference('not-a-reference')); // null
parseMapFile
function
Reads a Wesnoth-shaped .map file: key=value header lines (border_size=1, usage=map, and anything else the file carries), then one comma-separated row of terrain codes per line. The header is split off and kept, and the grid goes ...
import { parseMapFile } from '@datamoc/mwg/mwl';
const map = parseMapFile('border_size=1\nusage=map\n\nGg, Gg, Gg\nGg, Gg^Vh, Gg\n');
map.header.usage; // 'map'
map.width; // 3
map.height; // 2
map.codes; // ['Gg', 'Gg', 'Gg', 'Gg', 'Gg^Vh', 'Gg'] - overlays kept as written
parsePackageDocument
function
Reads a package document: the MWL tags package, asset, character, animation, sound_effect, music, script and import, into plain typed data. Macros and #includes are expanded first, as for any MWL file. Nothing is ...
import { parsePackageDocument } from '@datamoc/mwg/mwl';
const document = parsePackageDocument(
"[{ tag: 'package', id: 'marta', version: '1.0.0' }, { tag: 'character', id: 'marta', hitpoints: 28 }]",
);
console.log(document.characters[0]?.stats.hitpoints); // 28
parsePackageRef
function
Splits a reference written by packageRef back into its three parts.
import { parsePackageRef } from '@datamoc/mwg/mwl';
console.log(parsePackageRef('marta@1.0.0#abc').version); // '1.0.0'
parseTableColumns
function
Parse the compact name:type|name:type declaration used by MWL tables.
import { parseTableColumns } from '@datamoc/mwg/mwl';
console.log(parseTableColumns('id:string|cost:number'));
parseTerrain
function
Parse map text: one row per line, comma-separated codes. A token may be <side> <code> to mark that side's keep/start, which is how a leader knows where to appear.
import { parseTerrain } from '@datamoc/mwg/mwl';
const map = parseTerrain('Gg,Gg\nGg,1 Kh');
console.log(map.width, map.starts['1']); // 2 [{ x: 1, y: 1 }]
parseValue
function
Unquotes one raw attribute value and strips the _("...") gettext marker.
import { parseValue } from '@datamoc/mwg/mwl';
console.log(parseValue('_("Hold the line")', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
preprocess
function
Expands MWL macros, #ifdef blocks and #includes into flat source text, then rewrites the _("...") gettext marker into a form the JSON5 parser accepts. The marker keeps working because this pass already runs before structural parsing, ...
import { preprocess } from '@datamoc/mwg/mwl';
console.log(preprocess("[{ tag: 'game', schema: 0.1 }]"));
readAttributes
function
Read a compiled node with one shared coercion and diagnostic policy.
import { compile, readAttributes } from '@datamoc/mwg/mwl';
const game = compile("[{ tag: 'game', schema: 0.1 }]");
const { value } = readAttributes(game.roots[0]!, { schema: { type: 'string' } });
console.log(value.schema); // '0.1'
readChildren
function
Collect children without making every game adapter repeat this filter.
import { compile, readAttributes, readChildren } from '@datamoc/mwg/mwl';
const game = compile("[{ tag: 'game', schema: 0.1, children: [{ tag: 'side', id: 1 }] }]");
const sides = readChildren(game.roots[0]!, 'side', (child) => readAttributes(child, { id: { type: 'id' } }));
console.log(sides.value.length); // 1
readTableIndex
function
Builds an inverse index, retaining every row that shares a projected key.
import { readTableIndex, tableKey } from '@datamoc/mwg/mwl';
const rows = [{ item: 'potion', effect: 'heal' }, { item: 'herb', effect: 'heal' }];
const byEffect = readTableIndex(rows, { key: 'effect' });
console.log(byEffect.get(tableKey('heal'))?.length); // 2
readTableMap
function
Projects rows into a typed map. An array of column names is a native composite key and is encoded without collisions between strings, numbers, booleans, and null.
import { readTableMap, tableKey } from '@datamoc/mwg/mwl';
const effects = [{ item: 'potion', effect: 'heal', amount: 5 }];
const byEffect = readTableMap(effects, { key: ['item', 'effect'], value: (row) => row.amount });
console.log(byEffect.get(tableKey('potion', 'heal'))); // 5
registerRomDigest
function
Adds the SHA-256 of a known instrument ROM image to the refusal list. Digests are kept as lower-case hex and never ship with mwg, so the list is whatever a game or a studio registers.
import { detectRomImage, registerRomDigest, sha256Hex } from '@datamoc/mwg/mwl';
const bytes = new Uint8Array([1, 2, 3]);
registerRomDigest(await sha256Hex(bytes), 'example ROM');
console.log((await detectRomImage(bytes))?.reason); // 'digest'
requestedCapabilities
function
What a package asks for: its declared capabilities plus script when any script carries code.
import { parsePackageDocument, requestedCapabilities } from '@datamoc/mwg/mwl';
const document = parsePackageDocument("[{ tag: 'package', id: 'p', version: '1.0.0' }, { tag: 'script', id: 's', on: 'start', code: '1' }]");
console.log(requestedCapabilities(document)); // ['script']
satisfiesVersionRange
function
Checks a version against a small semver range: >=, >, <=, <, ^, ~, an exact version or a partial one (0.42 is 0.42.x), several separated by spaces (all must hold) and alternatives separated by ||. A pre-release or build ...
import { satisfiesVersionRange } from '@datamoc/mwg/mwl';
console.log(satisfiesVersionRange('0.43.0', '>=0.42 <1')); // true
console.log(satisfiesVersionRange('0.43.0', '^0.42.0')); // false
schema01
const
The 0.1 MWL tag schema: every tag with its attributes, children and ref targets. Pass a different record to compile/validate to extend or replace the vocabulary.
import { schema01 } from '@datamoc/mwg/mwl';
console.log(Object.keys(schema01).includes('game')); // true
schema10
const
The published MWL 1.0 vocabulary. Version 1.0 preserves the 0.1 tag and value contract while making the core structural requirements explicit. Migration is lossless for valid 0.1 data; game-owned extensions are still supplied through ...
import { MWL_SCHEMA_10, schema10 } from '@datamoc/mwg/mwl';
console.log(MWL_SCHEMA_10, Object.hasOwn(schema10, 'game')); // '1.0', true
sha256Hex
function
Lower-case hex SHA-256 of some bytes, through crypto.subtle.
import { sha256Hex } from '@datamoc/mwg/mwl';
console.log((await sha256Hex(new Uint8Array(0))).slice(0, 8)); // 'e3b0c442'
sideVisionGroups
function
The sides a scenario's content says see together, as groups ready for FactionFog.share.
import { sideVisionGroups, type MwlWorld } from '@datamoc/mwg/mwl';
declare const world: MwlWorld;
// sides 1 and 2 both wrote team_name=north, and side 3 asked for share_vision=none
console.log(sideVisionGroups(world)); // [['1', '2']]
typedRows
function
Reads a table's rows through its declared columns, so consumer code names fields instead of casting them. The columns need literal names and types for inference (as const, or the output of emitTableTypes); a widened MwlTableColumn[] ...
import { typedRows } from '@datamoc/mwg/mwl';
import type { MwlTableColumn } from '@datamoc/mwg/mwl';
const columns = [{ name: 'cost', type: 'number' }] as const satisfies readonly MwlTableColumn[];
const rows = typedRows({ columns, rows: [{ cost: 3 }] });
console.log(rows[0]?.cost); // 3
validAttributeValue
function
Whether a value satisfies a declared attribute type. Exported because the check is not only the tag schema's: a hook declares its own attributes (item 321), and a game's [hook] invocations are checked against that declaration by the same ...
import { validAttributeValue } from '@datamoc/mwg/mwl';
console.log(validAttributeValue('yes', 'boolean')); // true
validate
function
Validates a parsed node tree against a schema, returning diagnostics rather than throwing.
import { parse, validate } from '@datamoc/mwg/mwl';
console.log(validate(parse("[{ tag: 'game', schema: 0.1 }]"))); // []
validateCatalogNodes
function
Convenience for adapters that keep parsed nodes rather than compiled games.
import { parse, validateCatalogNodes } from '@datamoc/mwg/mwl';
console.log(validateCatalogNodes(parse('[game]\nschema=0.1\n[/game]'))); // []
validateHookAttributes
function
Check one [hook] call's own attributes against its declaration. Returns nothing for a hook the game declares without attributes: an undeclared shape is not this function's to invent.
import { validateHookAttributes } from '@datamoc/mwg/mwl';
const node = { tag: 'hook', attributes: { name: 'command:mark', value: 'ok' }, children: [], location: undefined };
console.log(validateHookAttributes(node, { id: 'command:mark', attributes: { value: 'string' } })); // []
validateHookReferences
function
Report a reference that has no implementation, using the same diagnostic shape as the schema validator. available holds type:name keys.
import { parseHookReference, validateHookReferences } from '@datamoc/mwg/mwl';
const reference = parseHookReference('predicate:holds');
console.log(validateHookReferences(reference ? [reference] : [], ['predicate:holds'])); // []
validateWorld
function
Checks a decoded value really is a world, and throws by name when it is not. Also rejects a __proto__/constructor/prototype key anywhere in the value (assertNoForbiddenKeys): MwlRuntime.restore merges this result into its own ...
import { createWorld, validateWorld } from '@datamoc/mwg/mwl';
console.log(validateWorld(createWorld()).turn); // 1
verifyPackage
function
Checks a .mwp end to end and reports instead of throwing: container (digests, limits, paths), schema, declared asset.sha256, every reference, the licence and source of each medium, the capabilities requested, requires.mwg, instrument ...
import { encodePackage, verifyPackage } from '@datamoc/mwg/mwl';
const source = "[{ tag: 'package', id: 'marta', version: '1.0.0' }]";
const bytes = await encodePackage([{ path: 'content.mwl', data: new TextEncoder().encode(source) }]);
console.log((await verifyPackage(bytes)).ok); // true
mwl/presentation
compilePresentation
function
Compiles optional scene/UI definitions, validating every reference against the game's manifest. Reusable components are expanded at build time; emitted data contains no parser or executable content.
import { compilePresentation, emitPresentationModule } from '@datamoc/mwg/mwl/presentation';
const data = compilePresentation([], { values: {}, actions: [] });
console.log(emitPresentationModule(data).startsWith('export const presentationData')); // true
emitPresentationModule
function
Emits data and literal ID types for a game's build script to write alongside its other MWL artifacts.
import { compilePresentation, emitPresentationModule } from '@datamoc/mwg/mwl/presentation';
console.log(emitPresentationModule(compilePresentation([], { values: {}, actions: [] })));
mwl/pyroeis
createPyroeisRuntime
function
Optional Lua integration host for a game-owned API, embedded modules and resumable handlers. The JSON-only ScriptHost stays independent. This host opens no system libraries, compiles text only and bounds calls, compilation and coroutine ...
import { createPyroeisRuntime } from '@datamoc/mwg/mwl/pyroeis';
const runtime = createPyroeisRuntime({ modules: { rules: 'return { cost = 4 }' } });
let gold = 10;
runtime.set('game.side', runtime.createTable({
get: (key) => key === 'gold' ? gold : null,
set: (key, value) => { if (key === 'gold' && typeof value === 'number') gold = value; },
}));
runtime.defineFunction('game.balance', () => gold);
runtime.defineYieldingFunction('game.ask', (prompt) => [prompt]);
...
createPyroeisScriptHost
function
Optional Lua 5.4 host. Pyroeis is loaded only by this subpath, never by mwg/mwl.
import { createPyroeisScriptHost } from '@datamoc/mwg/mwl/pyroeis';
const host = createPyroeisScriptHost();
console.log(host.evaluate('1 + 2')); // 3
host.dispose();
luaToJs
function
Translates Lua source to readable JavaScript without running it, for a game that is leaving Lua scripting behind. Metatables, coroutines, general goto and some patterns are not translated: they stay as TODO(lua2js) comments and ...
import { luaToJs } from '@datamoc/mwg/mwl/pyroeis';
const { code } = luaToJs('function double(n) return n * 2 end', { format: 'esm' });
physics
Cloth
class
A small pinned-point cloth mesh: Verlet integration over a grid of points joined by structural distance constraints, the top row pinned by default. Renderer-free arithmetic (roadmap 423): no physics engine, no allocations per step, ...
import { Cloth } from '@datamoc/mwg/physics';
const scarf = new Cloth({ width: 60, height: 20, segmentsX: 6, segmentsY: 2, gravity: { x: 0, y: 900 } });
for (let i = 0; i < 60; i++) scarf.update(1 / 60); // one fixed second
console.log(scarf.pointPosition(scarf.indexOf(3, 2)).y > 20); // true - the free end sags down
console.log(scarf.pointPosition(scarf.indexOf(0, 0))); // { x: 0, y: 0 } - the pinned edge never moves
PhysicsWorld
class
Rigid bodies and constraints in plain TypeScript, stepped on a fixed clock.
import { PhysicsWorld } from '@datamoc/mwg/physics';
const world = new PhysicsWorld();
world.addBox(0, 300, 800, 20, { isStatic: true });
const crate = world.addBox(100, 0, 40, 40, { restitution: 0.3 });
world.update(1 / 30); // two fixed steps
console.log(crate.position.y > 0); // true: it has started to fall
Spring
class
A damped chase spring for secondary motion: one bob pulled toward a moving anchor, the spring-damper complement to SpriteMotion (roadmap 424). Hair, hems, accessories and interface panels follow their anchor with lag, overshoot on a stop ...
import { Spring } from '@datamoc/mwg/physics';
const hair = new Spring({ stiffness: 120, damping: 8 });
hair.setAnchor(10, 0); // the head moved; the bob is still behind
hair.update(1 / 60);
console.log(hair.offset().x > 0); // true - the bob lags behind the anchor
for (let i = 0; i < 240; i++) hair.update(1 / 60);
console.log(hair.settled(0.01)); // true - a damped spring comes to rest at its anchor
physics/xpbd
XpbdWorld
class
A deterministic structure solver: point masses joined by springs, solved with extended position-based dynamics (XPBD), with the force in every link read back so a link can break at a real load rather than at a stretch.
import { XpbdWorld } from '@datamoc/mwg/physics/xpbd';
const world = new XpbdWorld({ gravity: 520 });
const wall = world.addNode({ x: 0, y: 0, fixed: true });
const weight = world.addNode({ x: 40, y: 0, mass: 1 });
const rope = world.addLink({ a: wall, b: weight, stiffness: 2e4, strength: 900 });
world.breaking = true;
for (let i = 0; i < 120; i++) world.step();
console.log(world.links[rope].broken, world.nodes[weight].y > 0);
roguelike
Actor
interface
Whose turn it is.
import { Scheduler, type Actor } from '@datamoc/mwg/roguelike';
interface Monster extends Actor { name: string }
const scheduler = new Scheduler<Monster>();
scheduler.add({ name: 'hero', speed: 1 });
scheduler.add({ name: 'fast rat', speed: 2 }); // acts twice as often
const acting = scheduler.peek(); // whoever's turn it is
scheduler.spend(1); // charge them for a normal-speed action, hand the turn on
BossPhases
class
A phased boss fight: HP-fraction thresholds that fire enter-once hooks, plus a cooldown rotation for the boss's abilities.
import { BossPhases, AbilityCycle } from '@datamoc/mwg/roguelike';
const phases = new BossPhases([0.66, 0.33]); // three stages
const entered = phases.check(0.3); // [1, 2] if a massive hit skipped straight past phase 1
const abilities = new AbilityCycle({ slam: 3, roar: 5 });
abilities.advance(1); // one turn of cooldowns ticking down
if (abilities.ready().includes('slam')) abilities.use('slam');
CavePassage
interface
Caves: chambers grown as blobs, joined by winding passages.
import { generateCave } from '@datamoc/mwg/roguelike';
const cave = generateCave({
width: 40,
height: 25,
seed: 7,
chambers: [
{ id: 'entrance', xRange: [2, 8], yRange: [2, 8], size: 12, passages: [] },
{ id: 'depths', xRange: [30, 37], yRange: [17, 22], size: 20, jagged: 60, passages: [] },
],
...
CombatHook
interface
A named-event registry whose handlers can be removed in bulk by whatever registered them.
import { HookRegistry } from '@datamoc/mwg/core';
const hooks = new HookRegistry<[amount: number]>();
const poisonRing = {};
hooks.on('turnStart', (amount) => console.log('poison ticks for', amount), poisonRing);
hooks.emit('turnStart', 3);
// the ring is removed - every hook it registered comes off in one call
hooks.offSource(poisonRing);
CombatHooks
class
Generic combat lifecycle hooks; the game owns HP, formulas, and when each event fires.
import { CombatHooks } from '@datamoc/mwg/roguelike';
const hooks = new CombatHooks<{ name: string }>();
const invulnerability = {};
hooks.on('beforeDamage', (ctx) => { ctx.amount = 0; }, invulnerability);
const result = hooks.modifyDamage({ name: 'hero' }, { name: 'rat' }, 5);
console.log(result.amount, result.prevented); // 0 true
DUNGEON_KINDS
const
The two terrain kinds every dungeon needs: wall (index 0) and floor (index 1). A game appends its own kinds (traps, doors) after these via DungeonOptions.kinds.
import { DUNGEON_KINDS } from '@datamoc/mwg/roguelike';
const kinds = [...DUNGEON_KINDS, { passable: false, transparent: true }]; // + a window
Doors
class
Open/closed/locked door state, the same shape Secrets already uses: the state itself is just terrain (a door swaps between its own open and closed terrain kinds, so passable/ transparent need no door-specific code at all), and this ...
import { Doors, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
const doors = new Doors(level);
doors.place(5, 5, { open: 1, closed: 0, locked: 'brass-key' });
doors.unlock(5, 5); // once the player is confirmed to hold the key
doors.open(5, 5);
console.log(doors.isOpen(5, 5));
Elevation
class
A discrete height per cell: raised platforms, cliffs, pits.
import { Elevation, Level, Pathfinder, FieldOfView, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
const heights = new Elevation(level);
heights.set(5, 5, 2); // a raised platform
const pathfinder = new Pathfinder(level);
pathfinder.find({ x: 0, y: 0 }, { x: 5, y: 5 }, { heights, climb: 1 });
const fov = new FieldOfView(level);
...
FeatureLayer
class
import { FeatureLayer } from '@datamoc/mwg/roguelike';
const features = new FeatureLayer<{ hp: number }>();
features.define('trap', {
interact: () => true,
consequence: (_cell, ctx) => { ctx.hp -= 5; },
persistent: false, // sprung once, then gone
});
features.place(42, 'trap');
...
FieldOfView
class
import { FieldOfView, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
const fov = new FieldOfView(level);
fov.update(5, 5, 8); // the hero's own sight, radius 8
console.log(fov.visible.has(level.index(6, 5))); // true - lit right now
console.log(fov.explored.has(level.index(6, 5))); // true - stays remembered after leaving
MultiStageAbility
class
import { MultiStageAbility } from '@datamoc/mwg/roguelike';
const dive = new MultiStageAbility([
{ name: 'windup', duration: 1 },
{ name: 'active', duration: 1 },
{ name: 'recovery', duration: 2 },
]);
dive.start();
console.log(dive.stage?.name); // 'windup'
...
MultiTurnBeam
class
Advances a beam one front per turn.
import { Level, FLOOR, WALL, MultiTurnBeam } from '@datamoc/mwg/roguelike';
const level = new Level(8, 1, [WALL, FLOOR], 1);
const beam = new MultiTurnBeam({ level, from: { x: 0, y: 0 }, target: { x: 3, y: 0 }, damage: 4 });
beam.start();
beam.advance(); // reaches x=1; call again on the next turn
OverworldTerrain
type
An overworld map: a whole realm as plain tile data.
import { generateOverworld } from '@datamoc/mwg/roguelike';
const realm = generateOverworld({ width: 96, height: 64, seed: 42 });
console.log(realm.terrain[0]); // 'water' | 'plains' | 'forest' | ...
console.log(realm.settlements.length); // roads join every one of them
Pathfinder
class
Routes across the map.
import { Pathfinder, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
const pathfinder = new Pathfinder(level);
const route = pathfinder.find({ x: 0, y: 0 }, { x: 10, y: 10 }); // one monster's own route
const distances = pathfinder.distanceMap({ x: 10, y: 10 }); // every monster reads the same map
RangeBand
interface
A damage/effect multiplier keyed to range band - the "melee bonus, falls off at range" or "point-blank penalty on a ranged weapon" family of modifiers many games attach to an attack once its distance is known. mwg supplies no default ...
import { rangeMultiplier, areaFalloffMultiplier, type RangeBand } from '@datamoc/mwg/roguelike';
const bands: RangeBand[] = [{ max: 2, multiplier: 1.5 }, { max: 6, multiplier: 1 }];
rangeMultiplier(1, bands); // 1.5 - point-blank bonus
rangeMultiplier(10, bands, 0.5); // 0.5 - past every band, falls back to `beyond`
areaFalloffMultiplier(0, [1, 0.5]); // 1 - first target hit, full damage
areaFalloffMultiplier(1, [1, 0.5]); // 0.5 - second target
Secrets
class
A cell that renders and blocks like its surroundings until revealed.
import { Secrets, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
const secrets = new Secrets(level);
secrets.conceal(5, 5, 0, 1); // disguised as WALL (0), reveals as FLOOR (1) once found
console.log(secrets.isSecret(5, 5)); // true - the cell reads as solid rock until found
secrets.discover(5, 5); // a search action succeeded
Stealth
class
Tracks one hidden unit's detection state. Detection is one-way and sticky: once found, a unit stays found until something explicitly re-hides it (reset), the same way a real ambush does not un-happen because the enemy that spotted you ...
import { Stealth } from '@datamoc/mwg/roguelike';
const assassin = new Stealth({ radius: 2 });
const guards = [{ x: 10, y: 10 }, { x: 5, y: 5 }];
if (assassin.checkDetection({ x: 4, y: 5 }, guards)) {
console.log('spotted! that guard spends its move noticing');
}
console.log(assassin.isDetected);
TargetingController
class
The input-facing half of targeting: a cursor a player moves over cells, the range and line-of-sight rule that decides whether the current aim is legal, an optional preview of the shape, and a confirm/cancel result. It carries no renderer: ...
import { Level, FLOOR, WALL, TargetingController } from '@datamoc/mwg/roguelike';
const level = new Level(12, 12, [WALL, FLOOR], 1);
const aiming = new TargetingController(level, {
origin: { x: 2, y: 2 },
range: 6,
shape: { kind: 'burst', radius: 1 },
});
aiming.move(1, 0); // keyboard: one cell east
...
TriggerTracker
class
Counts qualifying events (a landed hit, a heal, a kill) toward a streak, but restarts the streak once more than window turns pass without a qualifying event - the shape every "combo", "kill streak", or "stays hidden as long as you keep ...
import { TriggerTracker } from '@datamoc/mwg/roguelike';
const combo = new TriggerTracker(2); // a hit must land within 2 turns of the last
combo.trigger(1); // streak: 1
combo.trigger(2); // streak: 2 - within the window
combo.trigger(10); // streak restarts at 1 - the window lapsed
WALL
const
The two terrain kinds every map needs; a game adds its own alongside.
import { Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 15, [WALL, FLOOR], 0); // 0 = WALL, everything starts solid
level.set(5, 5, 1); // 1 = FLOOR
console.log(level.passable(5, 5)); // true
ballistica
function
Traces a projectile-like path through a level and reports its first collision.
import { ballistica, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(10, 1, [WALL, FLOOR], 1);
const result = ballistica(level, { x: 0, y: 0 }, { x: 9, y: 0 }, { stop: 'opaque' });
console.log(result.cells, result.stop); // path and first blocking cell, if any
chebyshevDistance
function
The straight-line ruler distance a roguelike uses: diagonals cost the same as a step.
import { chebyshevDistance, traceLine, hasLineOfSight, canTarget, resolveArea, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1); // an open floor
const origin = { x: 2, y: 2 };
const target = { x: 8, y: 5 };
chebyshevDistance(origin, target); // 6
traceLine(origin, target); // every cell the shot crosses
hasLineOfSight(level, origin, target); // true - nothing blocks it
...
compareDungeonArtifacts
function
Diffs two artifact snapshots (this port's own run against a reference implementation's, or a golden fixture), reporting every mismatch tagged with which stage it belongs to: 'graph' for the room graph and retry count, 'paint' for ...
import { compareDungeonArtifacts, checkDeterminism, type DungeonArtifacts } from '@datamoc/mwg/roguelike';
declare const mine: DungeonArtifacts;
declare const golden: DungeonArtifacts; // a reference implementation's own run
const mismatches = compareDungeonArtifacts(golden, mine);
for (const m of mismatches) console.log(m.stage, m.field); // 'graph' or 'paint'
// and the same seed must always reproduce itself, regardless of any reference
declare function generate(): DungeonArtifacts;
...
coneCells
function
Every cell in a widening spray from origin towards target: the aim snaps to the nearest of the 8 directions, the length is the distance aimed, and step i of length spans round(i / length * width) cells to each side of the centre ...
import { coneCells, chainTargets, knockbackPath, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const breath = coneCells({ x: 5, y: 5 }, { x: 5, y: 0 }, 3); // a dragon's breath, aimed north
const monsters = [{ x: 5, y: 4 }, { x: 5, y: 3 }, { x: 6, y: 2 }];
const arc = chainTargets(monsters, { x: 5, y: 5 }, 3, 2); // a lightning bolt hopping between them
const level = new Level(20, 20, [WALL, FLOOR], 1);
const shovedTo = knockbackPath(level, { x: 5, y: 5 }, { x: 1, y: 0 }, 3); // shoved 3 cells east
coneSector
function
Every cell inside a circular sector: an arc of degrees centred on the aim, rays cast across it every 0.5 degrees, each ray clamped to range and stopped by a wall. The union of those rays is the sector, so a wall in the arc shadows ...
import { coneSector, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
// a 60-degree cone, 6 cells deep, aimed east from (4, 10)
const cells = coneSector(level, { x: 4, y: 10 }, { x: 10, y: 10 }, { degrees: 60, range: 6 });
decideMonsterAI
function
One monster's turn: wander when the target is outside its own sight, hunt when it can see the target and is healthy, flee when it can see the target and its own HP has dropped to fleeBelow or lower. Built entirely from FieldOfView and ...
import { decideMonsterAI, Pathfinder, Level, WALL, FLOOR } from '@datamoc/mwg/roguelike';
const level = new Level(20, 20, [WALL, FLOOR], 1);
const pathfinder = new Pathfinder(level);
const decision = decideMonsterAI(level, pathfinder, { x: 5, y: 5 }, 0.8, { x: 8, y: 5 }, {
sightRadius: 6,
fleeBelow: 0.2,
});
if (decision.step) console.log(decision.state, decision.step); // 'hunt', a step towards the player
findFreeCell
function
A random passable cell, avoiding any listed as taken.
import { generateDungeon, findFreeCell, furthestRoom, rectCenter } from '@datamoc/mwg/roguelike';
const level = generateDungeon({ width: 40, height: 30 });
const start = rectCenter(level.rooms[0]);
const stairsRoom = furthestRoom(level, start); // put the exit far from the entrance
const monsterCell = findFreeCell(level, new Set([level.index(start.x, start.y)]));
generateDungeon
function
Rooms joined by corridors, seeded and reproducible.
import { generateDungeon, generateDungeonGraph } from '@datamoc/mwg/roguelike';
import { Random } from '@datamoc/mwg/core';
Random.push(12345); // the same seed always produces the same floor
const level = generateDungeon({ width: 60, height: 40, rooms: 12 });
Random.pop();
// generateDungeonGraph is the same pipeline, also returning the room graph and retries
const { level: level2, graph, retries, roomBuilders } = generateDungeonGraph({ width: 60, height: 40 });
hallBuilder
const
A plain filled rectangle: the generator's own original behaviour, as a builder.
import { generateDungeon, hallBuilder, pickBuilder, eligibleBuilders, type RoomBuilder } from '@datamoc/mwg/roguelike';
const pillaredHall: RoomBuilder = {
id: 'pillared',
minSize: 6,
paint(level, room, floor) {
level.fillRect(room, floor); // then carve pillars into the interior, game-side
},
};
...
hexConeCells
function
A widening hex-grid cone, with width extra cells on each side at its far end.
import { hexConeCells } from '@datamoc/mwg/roguelike';
const cells = hexConeCells({ x: 2, y: 2 }, { x: 5, y: 2 }, 2);
rectsOverlap
function
Whether two rectangles overlap, optionally requiring margin cells of clearance between them - the check generateDungeon itself uses so rooms never end up sharing a wall.
import { rectsOverlap } from '@datamoc/mwg/roguelike';
const roomA = { left: 0, top: 0, right: 4, bottom: 4 };
const roomB = { left: 5, top: 0, right: 9, bottom: 4 };
rectsOverlap(roomA, roomB); // false - adjacent, not overlapping
rectsOverlap(roomA, roomB, 2); // true - too close with a 2-cell margin required
resolveAreaOnLevel
function
Resolves an area using the level's topology, including hex lines, cones and bursts.
import { Level, FLOOR, resolveAreaOnLevel } from '@datamoc/mwg/roguelike';
const level = new Level(8, 8, [{ passable: true, transparent: true }, FLOOR], 1, 'hex');
const cells = resolveAreaOnLevel(level, { x: 2, y: 2 }, { x: 4, y: 2 }, { kind: 'line' });
rollRoster
function
Rolls a spawn/content roster: the regular entries, then whichever rare additions rolled in, then a per-entry alternative-variant swap over the regular entries only, then (unless disabled) a shuffle - the "add-rare, swap, shuffle" order a ...
import { rollRoster } from '@datamoc/mwg/roguelike';
const { roster, trace } = rollRoster(
[{ value: 'rat' }, { value: 'rat', alternative: { value: 'albino rat', chance: 0.02 } }],
[{ value: 'boss-rat', chance: 0.01 }] // a rare addition, on top of the regulars
);
selectDistinctCells
function
Picks count distinct cells out of candidates without replacement, falling back to however many exist when fewer are available rather than throwing or looping forever - a crowded region just gets fewer placements, the same "fail ...
import { candidateCells, cellsNear, selectDistinctCells } from '@datamoc/mwg/roguelike';
declare const level: import('@datamoc/mwg/roguelike').Level;
const FLOOR = new Set([1]);
// scatter 3 decorations anywhere on the floor
const pool = candidateCells(level, { terrain: FLOOR });
const { cells: decorations, trace } = selectDistinctCells(pool, 3);
// then release 2 more items clustered around one of those cells
...
rpg
AUTOMAP_EMPTY
const
The wildcard: an input cell holding it constrains nothing, an output cell holding it leaves the target alone.
import { automap, AUTOMAP_EMPTY, type AutomapTarget, type AutomapRule } from '@datamoc/mwg/rpg';
declare const map: AutomapTarget; // a real two-d/render.TileMap satisfies this structurally
const cornerRule: AutomapRule = {
width: 2, height: 2,
input: { ground: [1, AUTOMAP_EMPTY, AUTOMAP_EMPTY, AUTOMAP_EMPTY] },
outputs: [{ ground: [2, AUTOMAP_EMPTY, AUTOMAP_EMPTY, AUTOMAP_EMPTY] }],
};
...
EventRunner
class
import { EventRunner, GameState, type DialoguePresenter } from '@datamoc/mwg/rpg';
declare const present: DialoguePresenter; // two-d/ui.messageBoxPresenter(windows), typically
const runner = new EventRunner({ present, game: new GameState() });
await runner.run([
{ say: 'A cold wind blows.' },
{ setSwitch: 'doorOpen', value: true },
]);
FreeMover
class
Movement not snapped to any grid: a float position plus a continuous facing angle, for a twin-stick shooter, a bullet-hell, or any top-down game whose player moves and aims freely rather than stepping cell to cell. rpg.GridMover is the ...
import { FreeMover } from '@datamoc/mwg/rpg';
import type { MovableSprite } from '@datamoc/mwg/rpg';
declare const heroSprite: MovableSprite;
const mover = new FreeMover(heroSprite, 100, 100, { speed: 120 });
// in the game loop, from a joystick or WASD:
mover.move(1, 0, 1 / 60); // unnormalized direction; FreeMover normalizes it
console.log(mover.facing); // radians, updated to face the movement direction
GameState
class
Global switches and variables - the two pieces of state an RPG's events read and write most, kept as plain maps rather than anything fancier so mwg/core's SaveSystem can serialise them like any other plain data.
import { GameState } from '@datamoc/mwg/rpg';
const state = new GameState();
state.setSwitch('metShopkeeper', true);
state.setVariable('gold', 50);
console.log(state.switch('metShopkeeper'), state.variable('gold'));
const saved = JSON.stringify(state.toJSON());
const restored = GameState.fromJSON(JSON.parse(saved));
GridMover
class
Grid movement tweened between tiles, with a walk cycle - what turns "the player pressed right" into a sprite that glides from one tile to the next and faces the way it is going, rather than teleporting square to square.
import { GridMover } from '@datamoc/mwg/rpg';
import type { MovableSprite } from '@datamoc/mwg/rpg';
declare const heroSprite: MovableSprite;
const mover = new GridMover(heroSprite, 5, 5, { tileWidth: 32, tileHeight: 32, speed: 4 });
mover.moveBy(1, 0); // a tile check already confirmed this is passable
// in the game loop:
mover.update(1 / 60);
MoveRouteRunner
class
Drives a GridMover through a MoveRoute over successive update(dt) calls - the piece GridMover's own one-step-at-a-time moveBy/turnTo deliberately leaves out (see its own doc comment: whether a step is passable is the caller's ...
import { GridMover, MoveRouteRunner, type MoveRoute } from '@datamoc/mwg/rpg';
import type { MovableSprite } from '@datamoc/mwg/rpg';
declare const npcSprite: MovableSprite;
declare function tilePassable(x: number, y: number): boolean;
const mover = new GridMover(npcSprite, 3, 3, { tileWidth: 32, tileHeight: 32 });
const patrol: MoveRoute = {
steps: [{ dir: 'right' }, { dir: 'right' }, { wait: 1 }, { dir: 'left' }, { dir: 'left' }],
repeat: true,
...
QuestLog
class
Tracks which stage every known quest is on, against GameState's existing switches and variables - not a storage mechanism of its own. A quest's *definition* (its stages, its prerequisites) is static game content, supplied once, the same ...
import { QuestLog, GameState } from '@datamoc/mwg/rpg';
const state = new GameState();
const quests = new QuestLog();
quests.define({ id: 'rats', stages: [{ counter: { variable: 'ratsKilled', target: 5 } }] });
quests.track('rats');
state.setVariable('ratsKilled', 5);
quests.advanceStage('rats', state); // true - the counter stage is satisfied
...
activePage
function
The last page whose conditions all hold - the same convention RPG Maker uses, so an event is authored as an ordered list running from "default behaviour" to "most specific", and whichever specific case currently applies overrides the ...
import { activePage, conditionHolds, GameState, type MapEvent } from '@datamoc/mwg/rpg';
const state = new GameState();
const shopkeeper: MapEvent = {
id: 'shopkeeper', x: 5, y: 3,
pages: [
{ trigger: 'action', commands: [{ say: 'Welcome!' }] },
{ conditions: [{ switch: 'questDone', equals: true }], trigger: 'action', commands: [{ say: 'Thank you!' }] },
],
};
...
extractDialogueCatalog
function
Collects every say/ask line's text into an identity catalog - each key mapping to itself, the same shape MWL's own extractCatalog builds from _("...")-marked strings - so a translator has real entries to work from instead of a wall ...
import { parseDialogueText, extractDialogueCatalog } from '@datamoc/mwg/rpg';
const commands = parseDialogueText('@alice Hello.\n');
console.log(extractDialogueCatalog(commands).messages); // { 'Hello.': 'Hello.' }
importTwee
function
Reads a .twee file into an EventStoryScript.
import { importTwee, EventRunner, GameState, type DialoguePresenter } from '@datamoc/mwg/rpg';
declare const present: DialoguePresenter;
const story = importTwee(`:: Start
You stand at a crossroads.
[[Take the left path->Left]]
[[Take the right path->Right]]
...
parseDialogueText
function
A terse, line-based text format for straight (non-branching) dialogue - the format a text-only RPG (core plus rpg, no renderer at all) can use for its own EventCommand scripts without depending on two-d for anything. two-d/stage ...
import { parseDialogueText, EventRunner, GameState } from '@datamoc/mwg/rpg';
declare const game: GameState;
const runner = new EventRunner({
game,
present: async (request) => {
console.log(request.speaker ? `${request.speaker}: ${request.text}` : request.text);
},
});
...
questsFromRows
function
Groups a flat table of stage rows into QuestDefinitions a QuestLog can define - the same relational shape core.parseCSV already models for a flat table (actors.AffixTable, actors.buildEntities): a content designer edits two ...
import { parseCSV } from '@datamoc/mwg/core';
import { questsFromRows, QuestLog, type QuestStageRow } from '@datamoc/mwg/rpg';
const csv = `questId,counterVariable,counterTarget,description
rats,ratsKilled,5,Kill 5 rats
rats,,,Report back to the innkeeper`;
const rows = parseCSV<QuestStageRow>(csv, { columns: { counterTarget: 'number' } });
const quests = questsFromRows(rows);
...
simulation
Actor
interface
Whose turn it is.
import { Scheduler, type Actor } from '@datamoc/mwg/roguelike';
interface Monster extends Actor { name: string }
const scheduler = new Scheduler<Monster>();
scheduler.add({ name: 'hero', speed: 1 });
scheduler.add({ name: 'fast rat', speed: 2 }); // acts twice as often
const acting = scheduler.peek(); // whoever's turn it is
scheduler.spend(1); // charge them for a normal-speed action, hand the turn on
Campaign
class
Game-neutral level sequencing and carry-over state. A campaign adapter supplies the actual level runner and decides what a level result means; MWG only preserves order, transitions, reminders and serialisable state.
import { Campaign } from '@datamoc/mwg/simulation';
const campaign = new Campaign({
levels: [{ id: 'intro', run: (state) => ({ outcome: 'completed', state, next: null }) }],
start: 'intro', state: { gold: 0 },
});
campaign.completeCurrent({ outcome: 'completed', state: { gold: 10 }, next: null });
CampaignSave
class
One save slot for a whole campaign, over core.SaveSystem. SaveSystem deliberately owns one state value and mwl's persistence owns one world, so neither saves a campaign's level sequence, its world and its turn together; this is the ...
import { CampaignSave } from '@datamoc/mwg/simulation';
const saves = new CampaignSave<{ gold: number }, never, unknown, { hp: number }>({
namespace: 'my-campaign',
version: 1,
});
declare const campaign: { snapshot(): never };
declare const simulation: { snapshot(): never };
saves.save('slot1', { campaign, world: { terrain: '...' }, simulation }, 'Chapter 2');
...
EventPresentation
class
The documented integration between a turn-based simulation and its presentation, over the two pieces that already exist (simulation.SimulationRuntime and core.PresentationQueue) rather than a third kind of thing. It fixes the five ...
import { EventPresentation } from '@datamoc/mwg/simulation';
import type { SimulationRuntime } from '@datamoc/mwg/simulation';
import type { Actor } from '@datamoc/mwg/roguelike';
interface Fighter extends Actor {
id: string;
}
declare const runtime: SimulationRuntime<{ hp: number }, 'attack' | 'counter', { amount: number }, Fighter>;
...
SimulationRuntime
class
A small facade over one state + one scheduler + one RNG, for the interactive half of a turn-based simulation: the command a player just issued, dispatched through the game's own rule. advanceToInput/runScenario ...
import { SimulationRuntime, type SimulationRuntimeRule } from '@datamoc/mwg/simulation';
import { Scheduler, type Actor } from '@datamoc/mwg/roguelike';
import { Generator } from '@datamoc/mwg/core';
interface Fighter extends Actor { id: string; hp: number }
type State = { hero: Fighter; rat: Fighter };
type Command = 'attack';
type Event = { type: 'damage'; targetId: string; amount: number };
const rule: SimulationRuntimeRule<State, Command, Event, Fighter> = (state, _cmd, { random }) => {
...
TrainingEnvironment
class
Seeded, UI-free reset/step adapter over a game's existing SimulationRule. Observations and rewards use a fixed agent roster within each episode. A null action is legal only when all actions for that agent are masked. The rule owns idle ...
import { TrainingEnvironment } from '@datamoc/mwg/simulation';
const env = new TrainingEnvironment({ observationVersion: 'counter-v1',
initial: () => ({ x: 0 }), observe: s => [{ input: [s.x], mask: [true] }],
command: (_s, actions) => actions[0],
rule: s => ({ state: { x: s.x + 1 }, events: [], status: 'ready' }),
rewards: () => [1],
}, { maxSteps: 10 });
env.reset(7);
const frame = env.step([0]);
const checkpoint = env.snapshot();
...
advanceToInput
function
Advance automatic actions with an explicit work budget. Input actors are never spent. Rules own actor removal and queue replacement. A returned cost is charged after act(), even if that action ends the game; return null when the action ...
import { advanceToInput, type TurnRules } from '@datamoc/mwg/simulation';
declare const rules: TurnRules<{ id: string; isPlayer: boolean }>;
// runs monster turns automatically, stopping the moment the player needs to act
const result = advanceToInput(rules, 1000);
if (result.status === 'input') console.log('waiting on', result.actor.id);
imitationFromReplay
function
Turns a persisted run into imitation targets: walks the recorded actions against the same seeded environment they were played on, and hands back the (observation, action) pairs a game's own trainer fits - the same shape the maze chase ...
import { TrainingEnvironment, imitationFromReplay } from '@datamoc/mwg/simulation';
declare const env: TrainingEnvironment<{ x: number }, number, never>;
const result = imitationFromReplay(env, {
actions: ['left', 'right'],
events: [{ frame: 0, action: 'right' }],
seed: 7,
});
console.log(result.samples[0].action); // 1: 'right'
runHeadlessScenario
function
Execute a deterministic scenario without a renderer, returning state, outputs and RNG state for CI.
import { runHeadlessScenario } from '@datamoc/mwg/simulation';
const result = runHeadlessScenario({
seed: 7, initialState: { score: 0 }, commands: [1],
step: (state, command) => ({ state: { score: state.score + command }, events: [], status: 'ready' }),
});
runRollouts
function
Interactive rollouts at simulation speed, with a fresh seeded reset per episode. The evaluator is created once per batch and shares its buffers across agents. A synchronous signal is checked between steps; cancellation from another event ...
import { runRollouts, TrainingEnvironment } from '@datamoc/mwg/simulation';
import type { NeuralModel } from '@datamoc/mwg/ai';
declare const env: TrainingEnvironment<{ x: number }, number, never>;
declare const model: NeuralModel;
const episodes = runRollouts(env, model, { seeds: [1, 2, 3], trajectoryLimit: 2 });
runRolloutsAsync
function
One worker per episode shard, with isolated environments and bounded concurrency. A model and environment stay loaded for the shard's whole batch. Returned episodes stay in seed order independent of worker completion order. Abort or ...
import { runRolloutsAsync, type TrainingFactory } from '@datamoc/mwg/simulation';
import type { NeuralModel } from '@datamoc/mwg/ai';
declare const factory: TrainingFactory<{ maxSteps: number }, { x: number }, number, never>;
declare const model: NeuralModel;
const episodes = await runRolloutsAsync(factory, { maxSteps: 20 }, model, { seeds: [1, 2], jobs: 2 });
runScenario
function
Run commands without frames, input devices, or rendering, collecting ordered events. The random object is passed through unchanged. Reproducibility requires identical initial state, random state, commands, and rules that use only those ...
import { runScenario, type SimulationRule } from '@datamoc/mwg/simulation';
import { Generator } from '@datamoc/mwg/core';
type State = { hp: number };
const step: SimulationRule<State, 'attack', { damage: number }, Generator> = (state, _cmd, rng) => {
const damage = rng.int(3) + 1;
const hp = state.hp - damage;
return { state: { hp }, events: [{ damage }], status: hp <= 0 ? 'finished' : 'ready' };
};
...
runSeededEpisode
function
Plays one whole run with the AI choosing, from a named seed: the same seed and the same choose make the same decisions, so a run is comparable between builds, policies and machines. choose must be deterministic for that to hold - the ...
import { TrainingEnvironment, runSeededEpisode } from '@datamoc/mwg/simulation';
declare const env: TrainingEnvironment<{ x: number }, number, never>;
const run = runSeededEpisode(env, (observations) => [0], { seed: 7 });
console.log(run.steps, run.terminated);
validateSimulationReplay
function
Replays a journal from an initial snapshot and compares deterministic event batches. The initial snapshot is treated as a checkpoint, so any journal it carries is ignored. An optional final-state check catches rules that changed state ...
import { validateSimulationReplay, type SimulationRuntimeRule, type SimulationSnapshot } from '@datamoc/mwg/simulation';
import type { ActionJournalEntry } from '@datamoc/mwg/core';
import type { Actor } from '@datamoc/mwg/roguelike';
declare const initialSnapshot: SimulationSnapshot<unknown>;
declare const recordedJournal: readonly ActionJournalEntry<unknown, unknown>[];
declare const rule: SimulationRuntimeRule<unknown, unknown, unknown, Actor>;
declare const actorOf: (id: string) => Actor;
declare const actorId: (actor: Actor) => string;
const result = validateSimulationReplay(initialSnapshot, recordedJournal, {
...
skeleton
Bone
interface
Skeletal pose math, renderer-free: a bone tree, keyframed clips, and layered blending.
import * as Skeleton from '@datamoc/mwg/skeleton';
const puppet = Skeleton.create([
{ name: 'body' },
{ name: 'arm', parent: 'body', length: 12 },
]);
const mixer = Skeleton.createMixer(puppet, { id: 'idle', duration: 2, loop: true, frames: [
{ time: 0, rotations: {} },
{ time: 1, rotations: { arm: 90 } },
] });
...
advancePose
function
Advance all clip channels together, including crossfades and priority layers.
import { create, createMixer, advancePose } from '@datamoc/mwg/skeleton';
const mixer = createMixer(create([{ name: 'body' }]), { id: 'bob', duration: 1,
frames: [{ time: 0, rotations: {}, translations: { body: { x: 0, y: 4 } } }] });
const pose = advancePose(mixer, 0.1);
sampleTransformPose
function
Sample every channel with the same timeline as rotations: holds the first frame before it, the last past the end of a one-shot, and wraps a looping clip.
import { sampleTransformPose } from '@datamoc/mwg/skeleton';
const pose = sampleTransformPose({ id: 'idle', duration: 1, frames: [{ time: 0, rotations: {} }] }, 0.5);
solveTwoBoneIK
function
Solve a connected two-bone chain toward a world target without stretching. Positive bend rotates the second bone clockwise in screen coordinates. Origins on the second bone must be zero; the solver accepts rotation-only poses.
import { create, solveTwoBoneIK } from '@datamoc/mwg/skeleton';
const rig = create([{ name: 'upper', length: 10 }, { name: 'lower', parent: 'upper', length: 10 }]);
const pose = solveTwoBoneIK(rig, 'upper', 'lower', { x: 10, y: 10 });
validatePose
function
Refuse non-finite rotations and absolute local angles outside a joint's bounds.
import { create, validatePose } from '@datamoc/mwg/skeleton';
validatePose(create([{ name: 'knee', limits: { min: -120, max: 0 } }]), { knee: -30 });
testing
FakeAudioContext
class
A WebAudio double whose clock only moves when a test moves it. It records every buffer source that was started and ends them as advance() passes their end time, which is exactly the part of an AudioContext a streaming voice depends on ...
import { StreamChannel } from '@datamoc/mwg/audio';
import { FakeAudioContext, FakeBlockSource } from '@datamoc/mwg/testing';
const context = new FakeAudioContext();
const channel = new StreamChannel(context.asContext(), undefined, { pumpMs: 0 });
channel.play(new FakeBlockSource({ seconds: 1 }));
context.advance(2);
FakeRetroPlayer
class
A MidiSynth double for a retro engine: it records every message it was driven with and renders a flat level while any note is on (silence otherwise), so a test can assert what a renderer sent and read the shape of the output without a ...
import { FakeRetroPlayer } from '@datamoc/mwg/testing';
const synth = new FakeRetroPlayer(8000);
synth.event(0x90, 60, 100); // note on, middle C
const left = new Float32Array(4);
synth.render(left, new Float32Array(4), 0, 4);
console.log(synth.notesOn, left[0] > 0); // 1, true
FakeSocket
class
A WebSocketLike for LockstepClient's create option: sent holds what the client sent, receive(message) delivers a server message as JSON (receiveRaw delivers text as is), and close() fires onclose.
import { LockstepClient } from '@datamoc/mwg/core';
import { FakeSocket } from '@datamoc/mwg/testing';
const socket = new FakeSocket();
const client = new LockstepClient({ url: 'wss://example.test', create: () => socket });
client.connect();
socket.receive({ type: 'welcome', id: 'p1' });
SceneStep
type
A scene control API for tests (roadmap 408): named steps a game opts into, driven by name.
import { createSceneControls } from '@datamoc/mwg/testing';
const controls = createSceneControls();
const enterScenario = (name: string) => {}; // the game's own private scene flow
controls.register('enterScenario', () => enterScenario('the-freelands'));
controls.register('finishTurn', () => {});
exposeForTests
function
Publishes a game's inspectable state on window.__mwg_test__[name], where a browser test (BrowserDriver.evaluate) can reach it. One fixed place replaces a hook each game invents; a no-op outside a browser.
import { exposeForTests } from '@datamoc/mwg/testing';
declare const scene: unknown;
declare const hitSound: unknown;
exposeForTests('game', { scene, hitSound });
// in the test: await driver.evaluate(() => window.__mwg_test__.game.hitSound.plays);
fakeAudio
function
A Playable for Sound/Music's create option (create: fakeAudio works as is): play() counts and unpauses, pause() pauses.
import { Sound } from '@datamoc/mwg/audio';
import { fakeAudio } from '@datamoc/mwg/testing';
const audio = fakeAudio();
const sound = new Sound('hit.wav', { poolSize: 1, create: () => audio });
sound.play();
console.log(audio.playCount); // 1
fakeFetch
function
A fetch for any HttpTransport client's fetch option: respond answers each request, with a Response or with a plain value sent as a JSON body, and calls records them in order.
import { NewsClient } from '@datamoc/mwg/core';
import { fakeFetch } from '@datamoc/mwg/testing';
const fetch = fakeFetch(() => [{ id: '1', title: 'Patch notes', body: 'Fixed things.' }]);
const news = new NewsClient({ endpoint: 'https://example.test/news', fetch });
memoryStorage
function
Test doubles for the seams mwg already takes by injection, so a game's tests (and this repository's own) do not each rewrite them: a storage for SaveSystem, Settings, Collection and the stored counters, a Playable for ...
import { SaveSystem } from '@datamoc/mwg/core';
import { memoryStorage } from '@datamoc/mwg/testing';
const saves = new SaveSystem<{ gold: number }>({ namespace: 'test', version: 1, storage: memoryStorage() });
saves.save('slot1', { gold: 3 });
threads
openWorker
function
Opens a worker running a classic script, in a browser (a blob worker, which the shipped file:// page's policy allows) or in Node (worker_threads in eval mode), and returns null where neither exists. spawn is the one-task-per-worker ...
import { openWorker } from '@datamoc/mwg/threads';
const link = openWorker('globalThis.onmessage = (event) => globalThis.postMessage(event.data * 2);', {
message: (value) => console.log(value),
fail: (error) => console.warn(error.message),
});
link?.post(21);
spawn
function
Runs fn on a worker thread and resolves with its value, keeping the caller's thread free: the one synchronous block a game can never get back is the frame it spends waiting.
import { spawn } from '@datamoc/mwg/threads';
const sum = await spawn((a: number, b: number) => a + b, [40, 2]);
console.log(sum); // 42
//a task that never returns is abandoned rather than hanging the caller
const controller = new AbortController();
const pending = spawn(() => new Promise(() => {}), [], { signal: controller.signal });
controller.abort();
pending.catch((error: Error) => console.log(error.name)); // 'AbortError'
two-d
- audio
- battle
- bridge
- chess
- cloth
- colour-transform
- dialogue
- dungeon
- event-system
- headless
- hello-world
- i18n
- interface
- loading
- minigame
- movement
- multi-turn-beam
- mwl-presentation
- neural
- pacman
- retro-audio
- save-load
- spring
- string-editor
- tower-defense
- village
- world-transition
ActorAnimator
class
The convention AnimatedSprite and GridMover never had: standing still, moving, and performing an action are the only three states a character's animation is ever in, and an action is the only one of the three that interrupts the other ...
import { ActorAnimator, AnimatedSprite, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
const hero = new AnimatedSprite();
hero.add('idle-down', sheet.pick(0), { fps: 2 });
hero.add('move-down', sheet.range(1, 4), { fps: 10 });
hero.add('action-down', sheet.range(5, 7), { fps: 12, loop: false });
const animator = new ActorAnimator(hero, {
...
ActorAnimatorOptions
interface
import type { ActorAnimatorOptions } from '@datamoc/mwg/two-d/render';
const options: ActorAnimatorOptions = {
animationName: (state, facing) => `${state}-${facing}`,
variant: 'down',
};
AnimatedSprite
class
A sprite that plays named animations, and can still be tinted.
import { AnimatedSprite, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
const hero = new AnimatedSprite();
hero.add('idle', sheet.pick(0, 0, 0, 1), { fps: 2 });
hero.add('walk', sheet.range(6, 10), { fps: 10 });
hero.add('die', sheet.range(11, 14), { fps: 10, loop: false });
hero.add(
'thrust',
...
Animation
class
import { Animation } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const frames: Texture2D[];
const walk = new Animation(frames, { fps: 10 });
console.log(walk.duration); // frames.length / 10, in seconds
ArticulatedChain
class
A serpent's spine as pure positions.
import { ArticulatedChain } from '@datamoc/mwg/two-d/render';
const spine = new ArticulatedChain({ segments: 8, length: 12, bendLimit: 45 });
spine.pull(100, 0);
spine.update(1); // the body starts following the head
BLOB_SHAPES
const
Stitching a terrain edge or corner from many small tile pieces, chosen by which neighbours are the same terrain, rather than a game picking a frame per cell by hand.
import { BLOB_SHAPES } from '@datamoc/mwg/two-d/render';
console.log(BLOB_SHAPES.length); // 47
console.log(BLOB_SHAPES[0]); // { n: false, e: false, s: false, w: false, ne: false, se: false, sw: false, nw: false }
Bar
class
A filled proportion of a track - a health, mana or experience bar.
import { Bar } from '@datamoc/mwg/two-d/ui';
const hp = new Bar({ width: 120, height: 8, color: 0xcc3333, value: 30, max: 30 });
hp.setValue(18, 30); // took damage
console.log(hp.value); // 0.6
hp.setColor(0xff0000); // and it is bleeding
hp.resize(160, 8); // the window around it grew
Beam
class
A one-shot beam between two screen points: a fading line by default, a fading texture strip with texture - tiled, or stretched once along the span in the object form, with thin narrowing the body as it fades (see BeamOptions). The ...
import { Beam } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const rayTexture: Texture2D;
const zap = new Beam({ x: 0, y: 0 }, { x: 100, y: 0 }, { colour: 0x88ccff, duration: 0.2, thin: true });
const ray = new Beam({ x: 0, y: 0 }, { x: 100, y: 0 }, {
texture: { source: rayTexture, stretch: true },
width: 8,
thin: true,
...
Beams
class
Owns the beams a game has flashing: one add per ray, one update(dt) fading them all and forgetting the finished ones, and no bookkeeping left to the caller. Beams are parented here and destroyed with their beam; z-order stays the ...
import { Beams } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const layer: Container2D;
const beams = new Beams();
layer.addChild(beams);
beams.add({ x: 0, y: 0 }, { x: 100, y: 0 }, { colour: 0x88ccff });
beams.update(1 / 60);
...
BitmapLabel
class
A piece of text baked into a glyph-atlas texture instead of rasterised fresh on every change, the way Label (a thin wrapper over Pixi's Text) does. Label's own doc comment already names the cost this exists to avoid: "wasteful for ...
import { BitmapLabel } from '@datamoc/mwg/two-d/ui';
const fps = new BitmapLabel({ text: '60 fps', size: 12 });
fps.setText('59 fps'); // updated every frame, cheaply, unlike Label
Button
class
A clickable region with idle/hover/pressed/disabled states, a label, an icon, or both.
import { Button } from '@datamoc/mwg/two-d/ui';
const start = new Button({
width: 160,
height: 32,
text: 'Start',
onClick: () => console.log('start pressed'),
});
start.setDisabled(true); // greys it out and stops pointer events
...
COLOR_BLINDNESS_MATRICES
const
Brettel/Viénot-derived simulation matrices, the same values widely used by colourblindness simulators - a 5x4 (20-value) row-major RGBA matrix, Pixi's own ColorMatrixFilter format. Exported so the matrix data itself can be tested without ...
import { COLOR_BLINDNESS_MATRICES } from '@datamoc/mwg/two-d/render';
console.log(COLOR_BLINDNESS_MATRICES.deuteranopia.length); // 20
Camera
class
import { Camera } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const stage: Container2D;
const camera = new Camera({ zoom: 2 });
camera.setViewport(960, 540);
stage.addChild(camera.world);
camera.snapTo(160, 160); // jump to the start room, no easing
...
CameraOptions
interface
A view onto a world larger than the screen.
import { Camera, createCamera } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const stage: Container2D;
declare const hero: { x: number; y: number };
const camera = createCamera({ zoom: 3, deadzone: 0.3, pixelPerfectTileSize: 16 });
stage.addChild(camera.world);
camera.follow(hero);
...
ChainRig
class
The drawing half of a chain: one sprite per segment.
import { Texture, TextureSource } from 'pixi.js';
import { ArticulatedChain, ChainRig } from '@datamoc/mwg/two-d/render';
const skin = new Texture({ source: new TextureSource({ width: 8, height: 8 }) });
const spine = new ArticulatedChain({ segments: 3, length: 10 });
const rig = new ChainRig(spine, [skin, skin, skin]); // one Texture per segment
rig.update(1); // segments sit on the chain's points
Checkbox
class
A square box that is ticked or not, the toggle a settings row or an option list puts beside its label. It draws only the box and its tick - a caption is a Label the game places next to it, so the two can be laid out and translated ...
import { Checkbox } from '@datamoc/mwg/two-d/ui';
const fullscreen = new Checkbox({ checked: true });
fullscreen.onChange.add((on) => console.log('fullscreen', on));
fullscreen.toggle();
ClothSprite
class
A garment that follows with a slight lag: a Cloth simulation driving a textured mesh on the GPU path 414 ships (roadmap 423). The cloth spans the texture, the top edge starts pinned (a waistband, a held scarf end), and update(dt) steps ...
import { ClothSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const tunicTexture: Texture2D;
const tunic = new ClothSprite(tunicTexture, { segmentsX: 6, segmentsY: 8, gravity: { x: 0, y: 900 } });
tunic.update(1 / 60); // the hem answers gravity, the waistband stays pinned
console.log(tunic.movedCount()); // > 0 - everything below the pinned edge travelled
ColorTransformBatcher
class
The Pixi batcher extension TintedSprite (and everything built on it) renders through - see the module doc comment above for what it adds and why.
import { ColorTransformBatcher } from '@datamoc/mwg/two-d/render';
// registered automatically by TintedSprite.ts; naming the class directly is only ever
// needed to check which batcher an object ended up in, or in Pixi extension diagnostics
console.log(ColorTransformBatcher.extension.name); // 'mwg-color-transform'
DataTable
class
A columned table with a highlighted row, a sort order and paging - the roster or inventory grid that wants headers and a stable sort, where ListView is a flat one-column list.
import { DataTable } from '@datamoc/mwg/two-d/ui';
const roster = new DataTable({
columns: [{ key: 'name' }, { key: 'level' }],
rows: [{ name: 'Ash', level: 3 }, { name: 'Bo', level: 1 }],
});
roster.sortBy('level');
console.log(roster.rows[0].name); // 'Bo'
DeformableSprite
class
A sprite that deforms by pinned control points on the existing GPU mesh path.
import { DeformableSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const wingTexture: Texture2D;
const wing = new DeformableSprite(wingTexture, { columns: 4, rows: 4 });
wing.dragVertex(wing.indexOf(3, 0), 99, -6); // the wingtip bends on the GPU mesh
console.log(wing.vertexAt(wing.indexOf(0, 1)).x); // 0 - the pinned root stays put
console.log(wing.rotation); // 0 - dragging never turns the whole sprite
DialogueStage
class
A dialogue scene: a backdrop, characters standing in front of it, and whoever is speaking picked out from the rest.
import { DialogueStage } from '@datamoc/mwg/two-d';
import type { Texture } from 'pixi.js';
import type { SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const backdropTexture: Texture;
declare const heroSheet: SpriteSheet;
const stage = new DialogueStage(1280, 720);
stage.defineCharacter('hero', { sheet: heroSheet, expressions: { neutral: 0, happy: 1 } });
...
Dropdown
class
A closed button that opens a list of options - the "option button" a settings row or a scenario picker drops down.
import { Dropdown } from '@datamoc/mwg/two-d/ui';
const difficulty = new Dropdown({
options: [{ id: 'easy', label: 'Easy' }, { id: 'normal', label: 'Normal' }],
});
difficulty.open();
difficulty.move(1);
difficulty.confirm();
console.log(difficulty.selected?.id); // 'normal'
DropdownView
class
The widget for a Dropdown model: a button showing the current choice that opens a column of buttons beneath it, the highlighted row marked with Button.setSelected. Picking a row selects it, closes the list and fires the model's ...
import { DropdownView } from '@datamoc/mwg/two-d/ui';
const level = new DropdownView({
width: 180,
maxVisibleRows: 6,
options: Array.from({ length: 30 }, (_, i) => ({ label: `Level ${i + 1}` })),
});
level.model.onChange.add(({ index }) => console.log('chose', index));
level.model.open();
level.refresh();
...
EMPTY
const
The frame value meaning "nothing here"; a cell holding it gets no sprite at all.
import { EMPTY, TileMap, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
const map = new TileMap({ width: 10, height: 10, sheet });
map.addLayer('ground');
console.log(map.getTile('ground', 0, 0)); // EMPTY - nothing set there yet
Flights
class
Owns the projectile flights a game has in the air: one add per shot, one update(dt) driving them all, onArrive dispatched exactly once per arrival, and no bookkeeping left to the caller.
import { Flights } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const layer: Container2D;
declare const bolt: { x: number; y: number };
const flights = new Flights();
layer.addChild(flights);
const flight = flights.add(bolt, { x: 0, y: 0 }, { x: 200, y: 0 }, {
...
FloatingText
class
A damage number or a "+1 gold": text that rises and fades over its own lifetime, unlike Label's job of a static, positioned string. This is animation over a label, timed rather than laid out - a game positions one instance per pop-up at ...
import { FloatingText } from '@datamoc/mwg/two-d/ui';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const worldLayer: Container2D;
const popup = new FloatingText({ text: '-12', color: 0xff4444, hold: 0.5 });
popup.position.set(64, 96);
worldLayer.addChild(popup);
popup.update(0.5); // advance the rise/fade by half a second
...
FloatingTextStack
class
Owns the pop-ups a game spawns over world points: one push per number, one update(dt) to drive them all, and no bookkeeping left to the caller.
import { FloatingTextStack } from '@datamoc/mwg/two-d/ui';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const worldLayer: Container2D;
declare const hero: { x: number; y: number };
declare function update(dt: number): void;
const popups = new FloatingTextStack();
worldLayer.addChild(popups);
...
FogLayer
class
A smooth fog-of-war overlay: a small canvas with resolution pixels per tile, scaled up with linear filtering, so fog edges blur into the next cell instead of stepping by whole tiles. Draw it above terrain and characters, and call ...
import { FogLayer } from '@datamoc/mwg/two-d/render';
const fog = new FogLayer({
width: 20,
height: 20,
palette: [[0, 0, 0, 0], [0, 0, 0, 150], [0, 0, 0, 255]], // seen, remembered, unknown
});
fog.refresh((x, y) => (x < 5 ? 0 : 2));
Gradient
const
A gradient fill/stroke, passed to Shape2D.fill/.stroke the same way a plain colour is.
import { Shape2D, Gradient } from '@datamoc/mwg/two-d/render';
const sky = new Gradient({
type: 'linear',
start: { x: 0, y: 0 },
end: { x: 0, y: 1 },
colorStops: [
{ offset: 0, color: 0x1a1a3a },
{ offset: 1, color: 0x3a2a4a },
],
...
Grid
class
A column/row grid whose tracks are size or grow, the data-driven counterpart to GUI2's [grid]. Fixed tracks take their size; the rest of the space is divided between the grow tracks in proportion to their weight, so a sidebar can ...
import { Grid } from '@datamoc/mwg/two-d/ui';
const grid = new Grid({ columns: [{ size: 200 }, { grow: 1 }], rows: [{ grow: 1 }], gap: 8 });
const cell = grid.rect(0, 1, { x: 0, y: 0, width: 800, height: 600 });
console.log(cell); // the content column: x 208, width 592
Halo
class
The glow around a unit, an aura, a shrine's light: an animated sprite that follows a target and is drawn additively.
import { Halo, HALO_ANIMATION, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
declare const unit: { x: number; y: number };
const glow = new Halo({ frames: sheet.range(20, 22), animation: { fps: 6 }, offsetY: -6 });
glow.follow(unit.x, unit.y);
glow.update(1 / 60);
console.log(glow.playing === HALO_ANIMATION); // true - a halo is always playing its own cycle
HasColorAdd
interface
Carries the additive colour for one renderable.
import type { HasColorAdd } from '@datamoc/mwg/two-d/render';
import { packColorAdd } from '@datamoc/mwg/two-d/render';
function tintTowardsWhite(renderable: HasColorAdd, strength: number): void {
renderable.colorAdd = packColorAdd(strength, strength, strength);
}
HazeLayer
class
An atmospheric haze sheet: translucent weather and mood with soft edges, drift and density, on the GPU path (roadmap 425). Distinct from FogLayer, which is fog of war: this never hides gameplay, it only colours the air. The canvas stays ...
import { HazeLayer } from '@datamoc/mwg/two-d/render';
const far = new HazeLayer({ width: 400, height: 225, density: 0.3, tint: 0x9db8d6, drift: { x: 0.1, y: 0 } });
const near = new HazeLayer({ width: 400, height: 225, density: 0.5, tint: 0xcfd8e6, drift: { x: 0.4, y: 0.05 } });
far.update(1 / 60); // each plane drifts at its own pace, which reads as depth
near.update(1 / 60);
HelpScreen
class
A controls-reference or FAQ screen: a list of topics and the body of whichever one is highlighted, over the same ListView/Label a game already builds every other menu from - a recipe, not a new primitive, the way RebindScreen ...
import { HelpScreen } from '@datamoc/mwg/two-d/ui';
const help = new HelpScreen({
width: 320,
height: 200,
topics: [
{ title: 'Movement', body: 'Arrow keys or WASD to move.' },
{ title: 'Combat', body: 'Walk into an enemy to attack it.' },
],
});
...
IconGrid
class
A grid of icons, driven by the keyboard or the pointer.
import { IconGrid } from '@datamoc/mwg/two-d/ui';
import { Shape2D } from '@datamoc/mwg/two-d/render';
const potionIcon = new Shape2D().rect(0, 0, 16, 16).fill(0x66cc66);
const swordIcon = new Shape2D().rect(0, 0, 16, 16).fill(0xcccccc);
const bag = new IconGrid({
width: 160,
height: 80,
columns: 4,
...
Label
class
A piece of text, styled from the theme.
import { Label } from '@datamoc/mwg/two-d/ui';
const gold = new Label({ text: 'Gold: 0', color: 0xffe680 });
gold.setText('Gold: 50'); // skips the re-render if the string is unchanged
gold.setColor(0xff4444); // flash red on a loss, say
LayeredSprite
class
A character assembled from independently swappable, independently tintable layers - skin, eyes, hair, garments, worn equipment - rather than one flat sprite per combination. A second palette is a colour change on one layer; worn equipment ...
import { LayeredSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const skinTexture: Texture2D;
declare const hairTexture: Texture2D;
declare const swordTexture: Texture2D;
const hero = new LayeredSprite();
hero.addLayer('skin', skinTexture, 0);
hero.addLayer('hair', hairTexture, 1);
...
LightningArc
class
The position data for a jagged line between two points - a lightning bolt, a tether, a chain lightning link - the temporised two-point visual two-d/render had no helper for. Owns geometry only, the same division Projectile draws for a ...
import { LightningArc } from '@datamoc/mwg/two-d/render';
const bolt = new LightningArc({ x: 0, y: 0 }, { x: 100, y: 0 }, { duration: 0.2, segments: 4 });
console.log(bolt.points.length); // 6 - both endpoints plus 4 interior points
const expired = bolt.update(0.25);
console.log(expired); // true - the instant it outlives its duration
LiquidLayer
class
An animated liquid surface under a tile map: one scrolling tiling quad per liquid cell, so each cell can carry its own visibility tint (setCellColor, the same idea as TileMap.setCellColor) without showing liquid in cells the player has ...
import { Texture } from 'pixi.js';
import { LiquidLayer } from '@datamoc/mwg/two-d/render';
const water = new LiquidLayer({
texture: Texture.EMPTY,
width: 8,
height: 8,
isLiquid: (x, y) => y > 5,
});
water.setCellColor(3, 6, 0xffffff); // lit; a tint of 0 hides the cell
...
ListView
class
A scrolling list of rows, driven by the keyboard.
import { ListView } from '@datamoc/mwg/two-d/ui';
const menu = new ListView({
width: 160,
height: 96,
items: [
{ text: 'Attack' },
{ text: 'Item' },
{ text: 'Flee', disabled: true },
],
...
LoadingScreen
class
A themed loading view driven by LoadQueue.snapshot, with game-owned retry/cancel hooks.
import { LoadingScreen } from '@datamoc/mwg/two-d/ui';
import { LoadQueue } from '@datamoc/mwg/core';
const queue = new LoadQueue();
queue.add({ id: 'tileset', run: async () => { await Promise.resolve(); } });
const screen = new LoadingScreen({
width: 320,
height: 200,
title: 'Loading',
...
MarkdownSpan
interface
Minimal inline markdown for UI text: bold and italic spans, nothing block-level. **bold** and __bold__ render bold, *italic* and _italic_ italic (including the combined ***both***), a backslash escapes a marker (\* stays a ...
import { parseMarkdown, stripMarkdown } from '@datamoc/mwg/two-d/ui';
parseMarkdown('Take **two** *small* coins');
// [{ text: 'Take ', bold: false, italic: false },
// { text: 'two', bold: true, italic: false },
// { text: ' ', bold: false, italic: false },
// { text: 'small', bold: false, italic: true },
// { text: ' coins', bold: false, italic: false }]
stripMarkdown('Take **two** coins'); // 'Take two coins'
MarkupText
class
The canvas backend for inline markup, and the counterpart to RichLabel's HTML text: the same MarkupSpan stream, laid out once by layoutMarkupLines and positionMarkupLines, drawn here as one Text2D per styled run (colour, size, ...
import { MarkupText } from '@datamoc/mwg/two-d/ui';
const description = new MarkupText({ text: 'Pay <b>10</b> gold<img>coin.png</img>', maxWidth: 240 });
description.setText('Pay <b>15</b> gold<img>coin.png</img>');
MarqueeLabel
class
One line of text that stays on one line: when it is wider than width it slides back and forth inside a clipped window (marqueeOffset), driven by update(dt). Under reduced motion it holds still, cut short with an ellipsis, instead of ...
import { MarqueeLabel } from '@datamoc/mwg/two-d/ui';
const title = new MarqueeLabel({ width: 160, text: 'A very long track title that cannot fit' });
title.update(1 / 60); // once a frame from the scene
MessageBox
class
The dialogue box.
import { MessageBox } from '@datamoc/mwg/two-d/ui';
const box = new MessageBox({
width: 400,
height: 120,
pages: [
{ text: 'The old door creaks open. {sound:door-creak.wav}' },
{ speaker: 'Guard', text: 'Who goes there?' },
],
choices: [{ text: 'A friend' }, { text: 'None of your business' }],
...
MessageLog
class
A player-facing message pane: each message is its own block, coloured by its level, and the oldest blocks fall away once the wrapped lines pass maxLines. The classic roguelike "you hit the rat" strip; a Toast is transient and a ...
import { MessageLog } from '@datamoc/mwg/two-d/ui';
const log = new MessageLog({ wrapWidth: 200, maxLines: 4 });
log.add('You find a dewdrop.', 'positive');
log.add('You are starving.', 'negative');
console.log(log.entryCount); // 2
Meter
class
A discrete meter: N icons with a value filled, the Zelda heart row or star rating to Bar's continuous fill. Fractions clip mid-icon through a mask, so halves (or any fraction) work identically for flat shapes and game-supplied textures, ...
import { Meter } from '@datamoc/mwg/two-d/ui';
const hearts = new Meter({ count: 5, value: 3 });
hearts.setValue(2.5); // down to two and a half hearts
console.log(hearts.value); // 2.5
Minimap
class
A downscaled picture of a level, built from exactly the data roguelike.FieldOfView already tracks - explored, distinct from visible - plus a per-cell colour a game supplies, since mwg has no opinion on what a wall or a floor should ...
import { Minimap } from '@datamoc/mwg/two-d/render';
const minimap = new Minimap({ widthInCells: 40, heightInCells: 24, cellSize: 2 });
const explored = new Set([0, 1, 40]);
minimap.sync(explored, (x, y) => ((x + y) % 2 === 0 ? 0x3a3a2a : 0x2f5a2f));
minimap.setMarker(0, 0, Math.PI / 2); // the hero, facing down
console.log(minimap.exploredCount); // 3
NO_COLOR_ADD
const
the packed value of no additive colour at all - what an untinted sprite carries.
import { NO_COLOR_ADD, TintedSprite } from '@datamoc/mwg/two-d/render';
declare const rat: TintedSprite;
console.log(rat.colorAdd === NO_COLOR_ADD); // true - fresh sprites start with no additive colour
NeonGlow
class
A soft neon glow from a shape or mask: a radial sprite drawn additively, the overlay Halo already provides, with colour, radius, intensity and flicker (roadmap 426). A global bloom stays out on purpose: one sprite per sign is cheaper, ...
import { NeonGlow } from '@datamoc/mwg/two-d/render';
const sign = new NeonGlow({ color: 0x37e0ff, radius: 48, flicker: { amount: 0.15, frequency: 2 } });
sign.position.set(200, 120);
sign.update(1 / 60); // the tube breathes; under reduced motion it holds steady instead
NinePatch
class
A panel that can be resized without distorting its frame.
import { NinePatch } from '@datamoc/mwg/two-d/ui';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const panelTexture: Texture2D;
const panel = new NinePatch(panelTexture, { border: 8 });
panel.resize(200, 120);
console.log(panel.border); // { left: 8, top: 8, right: 8, bottom: 8 }
panel.alpha = 0.6; // fading the panel needs no extra API, unlike width/height/tint above
Node2D
class
A plain grouping container under an MWG-owned name, for the common "just group some children" need Scene2D.stage itself covers for the whole scene - a sub-layer inside it (a HUD strip, a creature layer, a popup stack) still needs its own ...
import { Node2D, Shape2D } from '@datamoc/mwg/two-d/render';
const hud = new Node2D();
hud.addChild(new Shape2D().rect(0, 0, 100, 20).fill(0x202020));
hud.x = 12;
hud.y = 12;
OnScreenKeyboard
class
An on-screen keyboard: rows of keys as data, rendered through the theme's own Button, each tap pressing its key through pressKey - dispatchText for a typing key, dispatchAction for an action key - so a TextPrompt (or any listener ...
import { OnScreenKeyboard, UPPERCASE_KEYBOARD } from '@datamoc/mwg/two-d/ui';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const layer: Container2D;
const keyboard = new OnScreenKeyboard(UPPERCASE_KEYBOARD, { width: 320 });
keyboard.x = 20;
keyboard.y = 240;
layer.addChild(keyboard);
// taps type through core.Input, exactly like a physical key
ParticleEmitter
class
A pooled particle emitter: sparks off a sword hit, dust under a footstep, rain over a map.
import { ParticleEmitter, type Texture2D } from '@datamoc/mwg/two-d/render';
declare const sparkTexture: Texture2D;
const sparks = new ParticleEmitter({
texture: sparkTexture,
max: 64,
life: [0.2, 0.4],
speed: [80, 160],
gravity: { x: 0, y: 300 },
...
PinnedDeform
class
A pinned-point deformation grid: the renderer-free math behind roadmap item 414.
import { PinnedDeform } from '@datamoc/mwg/two-d/render';
const wing = new PinnedDeform({ columns: 4, rows: 3, width: 96, height: 64 });
wing.drag(wing.indexOf(3, 0), 99, -6); // the wingtip follows the bone
console.log(wing.at(wing.indexOf(0, 1))); // { x: 0, y: 32 } - the pinned root never moves
console.log(wing.movedCount()); // 1 - only the dragged tip left its rest position
Projectile
class
Tweens a sprite's position in a straight line, world units to world units.
import { Projectile } from '@datamoc/mwg/two-d/render';
declare const boltSprite: { x: number; y: number };
const bolt = new Projectile(boltSprite, { x: 0, y: 0 }, { x: 200, y: 0 }, { speed: 400 });
const arrived = bolt.update(1 / 60);
console.log(bolt.progress); // small, just past 0 - one frame into the flight
console.log(arrived); // false - not there yet
console.log(boltSprite.x); // moved partway from 0 towards 200
QualityScaler
class
Frame-time-driven quality scaling over item 360's static fit: fitResolution answers what a device can afford once, and this watches what actually happens, stepping the backing-store ratio down when frames keep missing their budget and ...
import { QualityScaler } from '@datamoc/mwg/two-d';
const scaler = new QualityScaler({ ceiling: 2 });
for (let i = 0; i < 60; i++) scaler.observe(1 / 30);
console.log(scaler.ratio); // 1.75
RENDERING_DECISIONS
const
The framework's rendering choices by workload. This is data, not a global renderer switch: each game imports only the module it uses and keeps 2D Pixi and optional 3D Babylon paths independent.
import { RENDERING_DECISIONS } from '@datamoc/mwg/two-d/render';
const particles = RENDERING_DECISIONS.find((d) => d.workload === 'particles');
console.log(particles?.preferred); // 'PixiJS for 2D, Babylon.js for 3D'
RPGM_FLOOR_AUTOTILE_TABLE
const
The standard MV floor-autotile shape table (48 shapes), for slots 0-1: which four atlas quadrants each baked shape assembles, in 24px source cells. Entries are [x, y] offsets, row-major [topLeft, topRight, bottomLeft, bottomRight], ...
import { RPGM_FLOOR_AUTOTILE_TABLE, RPGM_WALL_AUTOTILE_TABLE } from '@datamoc/mwg/two-d/render';
console.log(RPGM_FLOOR_AUTOTILE_TABLE.length); // 48 - one entry per baked shape
console.log(RPGM_WALL_AUTOTILE_TABLE.length); // 16 - walls cycle every 16 shapes
console.log(RPGM_FLOOR_AUTOTILE_TABLE[47]); // [[0,0],[1,0],[0,1],[1,1]] - the full tile
RadioGroup
class
One choice among several, where a checkbox would allow many: exactly one option is selected, arrows move the selection (which selects at once, the way native radio groups do), and a tap selects directly. Disabled options are skipped by the ...
import { RadioGroup } from '@datamoc/mwg/two-d/ui';
const side = new RadioGroup({ options: [{ text: 'White' }, { text: 'Black' }] });
side.onChange.add((index) => console.log('side', index));
side.select(1);
RebindScreen
class
A ready-made rebinding flow over Input's existing bind/keysFor/actionsForKey, the same way IconGrid is a ready-made inventory screen over Inventory rather than something every game builds by hand. confirm on a row starts ...
import { RebindScreen } from '@datamoc/mwg/two-d/ui';
const screen = new RebindScreen({
width: 200,
height: 120,
actions: ['moveUp', 'moveDown', 'confirm'],
label: (action) => action, // a game usually maps this to a translated string
});
screen.handleAction('confirm'); // starts capturing a new key for the highlighted action
...
RevealState
interface
Progressive text display - the typewriter reveal every dialogue box wants, shared rather than reimplemented per widget. MessageBox pages, Label lines and RichLabel markdown all run on this one character count; what differs is only ...
import { startReveal, advanceReveal, completeReveal, revealComplete } from '@datamoc/mwg/two-d/ui';
const reveal = startReveal('Hello.'.length, 40);
advanceReveal(reveal, 0.5); // 20 characters per half second at speed 40
revealComplete(reveal); // false
completeReveal(reveal); // skip ahead, for a confirm press mid-reveal
revealComplete(reveal); // true
RichLabel
class
A label that renders basic inline markdown (**bold**, __bold__, *italic*, _italic*, combined ***both***) through Pixi's HTMLText, which is what makes mixed styles inside one string possible at all - plain Text styles the ...
import { RichLabel } from '@datamoc/mwg/two-d/ui';
const blurb = new RichLabel({ text: 'Take **two** *small* coins' });
blurb.setText('Take **three** coins'); // skips the re-render if the string is unchanged
RpgmAutotileAtlas
class
Caches RPG Maker autotile frame descriptions for one fixed shape table.
import { RpgmAutotileAtlas, type RpgmAutotileShapeTable } from '@datamoc/mwg/two-d/render';
declare const shapeTable: RpgmAutotileShapeTable;
const atlas = new RpgmAutotileAtlas(shapeTable);
const frame = atlas.get(7, 0, 3);
console.log(frame.quadrants.length); // 4
ScreenEffects
class
A full-screen colour wash over everything else: fade out to black between floors, flash white on a critical hit, hold a red tint while poisoned.
import { ScreenEffects } from '@datamoc/mwg/two-d/render';
const effects = new ScreenEffects({ width: 640, height: 360 });
// add effects last: game.stage.addChild(effects);
effects.flash(0.2, 0xffffff); // a critical hit
const done = effects.update(1 / 60);
console.log(done); // false - still mid-flash
console.log(effects.isBusy); // true
...
ScrollBox
class
A clipped viewport with a scrollbar: put rows in content and they scroll inside the box, everything outside it hidden by a mask. The scrollbar is drawn from the theme; the wheel, scrollBy and scrollIntoView all run through ...
import { ScrollBox } from '@datamoc/mwg/two-d/ui';
const box = new ScrollBox({ width: 200, height: 120, contentHeight: 400 });
box.scrollBy(60);
console.log(box.offset); // 60
ScrollDrag
class
Touch scrolling as plain numbers: feed it pointer positions and it says how far the content moves while the finger is down, then keeps coasting after release with a friction that decays the speed. No pointer events or clock of its own, so ...
import { ScrollDrag } from '@datamoc/mwg/two-d/ui';
const drag = new ScrollDrag();
drag.start(200, 0);
console.log(drag.move(170, 0.1)); // 30: the content follows the finger up
drag.end();
console.log(drag.step(1 / 60) > 0); // true: it keeps coasting
ScrollText
class
A wrapped Label in a ScrollBox that sizes itself: the box is as tall as the text up to maxHeight, and scrolls (wheel, touch, keys, scrollbar) beyond it, so a long hint, credit list or result message never spills over what is around ...
import { ScrollText } from '@datamoc/mwg/two-d/ui';
const hint = new ScrollText({ width: 220, maxHeight: 60 });
hint.setText('Span the gorge with at most 400 of budget. Cables only pull; beams push as well.');
console.log(hint.box.viewportHeight <= 60); // true
SettingsScreen
class
A ready-made settings screen over core.Settings: music and sound-effect sliders, a mute checkbox, a zoom slider, one row per game-defined custom descriptor, a controls page (RebindScreen), and a reset-to-defaults row. Every change ...
import { Settings } from '@datamoc/mwg/core';
import { SettingsScreen } from '@datamoc/mwg/two-d/ui';
const settings = new Settings({ namespace: 'my-game' });
const screen = new SettingsScreen({
settings,
custom: [{ kind: 'boolean', key: 'hints', label: 'Hints' }],
});
screen.handleAction('down');
...
Shape2D
class
Vector drawing - rectangles, circles, polygons, lines - under an MWG-owned name.
import { Shape2D } from '@datamoc/mwg/two-d/render';
const marker = new Shape2D().circle(0, 0, 6).fill(0xff4040).stroke({ color: 0x000000, width: 1 });
marker.x = 100;
marker.y = 60;
SkeletalSprite
class
Bones driving drawn parts (roadmap 400), the drawing half of the skeleton module.
import * as Skeleton from '@datamoc/mwg/skeleton';
import { Texture, TextureSource } from 'pixi.js';
import { SkeletalSprite } from '@datamoc/mwg/two-d/render';
const puppet = Skeleton.create([{ name: 'arm', length: 12 }]);
const mixer = Skeleton.createMixer(puppet, {
id: 'idle', duration: 1, loop: true,
frames: [{ time: 0, rotations: { arm: 0 } }, { time: 1, rotations: { arm: 90 } }],
});
const rig = new SkeletalSprite(puppet, mixer);
...
Skins
class
Per-widget and per-state skins, looked up by name - the data-driven half of what GUI2 puts in data/gui/*.cfg, and the thing the one global Theme has no room for.
import { Skins } from '@datamoc/mwg/two-d/ui';
const skins = new Skins();
skins.define('*', 'idle', { background: 0x101018, text: 0xe8e8f0 });
skins.define('button', 'pressed', { background: 0xffe680, text: 0x101018 });
console.log(skins.resolve('button', 'pressed').text); // 0x101018, the override
console.log(skins.resolve('label', 'idle').background); // 0x101018, the wildcard
Slider
class
A draggable knob on a track, for a volume, difficulty or zoom setting.
import { Slider } from '@datamoc/mwg/two-d/ui';
const volume = new Slider({ width: 200, min: 0, max: 100, step: 5, value: 40 });
volume.onChange.add((value) => console.log('volume', value));
volume.setValue(75);
Spinner
class
A small up/down stepper: tap the top half to add one step, the bottom half to take one away. It draws only the arrows; the current value is a Label the game puts beside it, the same split Checkbox and Slider keep from their captions.
import { Spinner } from '@datamoc/mwg/two-d/ui';
const level = new Spinner({ min: 1, max: 99, value: 1 });
level.increment();
console.log(level.value); // 2
SpringSprite
class
A debug line from a spring's anchor to its bob: one straight segment that stretches, overshoots and settles with the simulation. A game draws hair or hems by placing its own sprites with Spring.place, and keeps this around while tuning ...
import { Spring } from '@datamoc/mwg/physics';
import { SpringSprite } from '@datamoc/mwg/two-d/render';
const spring = new Spring({ stiffness: 120, damping: 8 });
const line = new SpringSprite({ spring });
spring.setAnchor(10, 0);
spring.update(1 / 60);
line.sync(); // the segment now leans toward the anchor
Sprite2D
class
A plain, untinted sprite under an MWG-owned name - the neutral case TintedSprite is not: that class exists to add the per-sprite multiply-and-add colour transform, so it is the wrong reach for a sprite that needs no colour effect at all. ...
import { Sprite2D } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const heroTexture: Texture2D;
const hero = new Sprite2D(heroTexture);
hero.x = 40;
hero.y = 60;
SpriteAttachment
class
Ties a second sprite's position to a first one's, with its own offset and its own optional lifetime - a shadow flat beneath a unit, a status icon floating above it, a shield glyph at a corner. StatusVisuals only tints a sprite that ...
import { SpriteAttachment } from '@datamoc/mwg/two-d/render';
declare const shadowSprite: { x: number; y: number };
declare const statusIcon: { x: number; y: number };
const shadow = new SpriteAttachment(shadowSprite, { offsetY: 4 });
const poisoned = new SpriteAttachment(statusIcon, { offsetY: -20, duration: 3 });
shadow.follow(100, 200);
poisoned.follow(100, 200);
...
SpriteGroup
class
A crowd that only animates its on-screen members.
import { SpriteGroup, AnimatedSprite, Camera } from '@datamoc/mwg/two-d/render';
declare const camera: Camera;
const crowd = new SpriteGroup();
const goblin = new AnimatedSprite();
crowd.add(goblin);
function onFrame(dt: number): void {
crowd.update(camera, dt); // goblins off screen keep their frame, for free
...
SpriteMotion
class
Squash, hop, bob, wobble, spin, shear and flip over a display object's transform, each running through a Tweener so reduced motion is honoured once, here, instead of by every game's own scale/Math.sin write.
import { SpriteMotion } from '@datamoc/mwg/two-d/render';
const rat = { x: 0, y: 0, rotation: 0, scale: { x: 1, y: 1 }, skew: { x: 0, y: 0 } };
const motion = new SpriteMotion(rat);
motion.hop({ height: 6, duration: 0.3 }); // up and back down
motion.squash({ amount: 0.2, duration: 0.3 }); // composes with the hop
motion.flip('x'); // face the other way, composes with both
motion.update(1 / 60); // in the game loop
...
StageScript
class
Runs a scene.
import { StageScript, DialogueStage, type StageCommand } from '@datamoc/mwg/two-d';
import { WindowStack } from '@datamoc/mwg/two-d';
import type { Texture } from 'pixi.js';
declare const backdropTexture: Texture;
const stage = new DialogueStage(1280, 720);
const windows = new WindowStack();
const script = new StageScript({ stage, windows, backdrop: () => backdropTexture });
...
StatsScreen
class
A read-only "your stats" display: one label: value line per StatRow, over the same Label every other screen already builds from - a recipe, not a new primitive, the way HelpScreen composes ListView/Label rather than inventing ...
import { StatsScreen } from '@datamoc/mwg/two-d/ui';
const screen = new StatsScreen({
width: 200,
stats: [
{ label: 'Runs completed', value: '12' },
{ label: 'Best depth', value: '7' },
],
});
...
StatusVisuals
class
Turns a set of active status-effect names into one additive colour on a sprite, so a game's own actors.applyStatusEffect handles - which know nothing about rendering - can drive what a character looks like without either side importing ...
import { StatusVisuals, TintedSprite, type Texture2D } from '@datamoc/mwg/two-d/render';
declare const ratTexture: Texture2D;
const rat = new TintedSprite(ratTexture);
rat.tint = 0x8080ff; // a team colour, untouched by anything below
const visuals = new StatusVisuals(rat, {
styles: {
poisoned: { color: 0x00ff00, strength: 0.5 },
burning: { color: 0xff6600, strength: 0.6, pulseRate: 2 },
...
StoryScreen
class
A full-screen story beat: a backdrop image, a title and the text over it, advanced by a click - the campaign interlude a scenario shows between maps.
import { StoryScreen, StorySequence } from '@datamoc/mwg/two-d/stage';
const sequence = new StorySequence([{ title: 'Prologue', text: 'The war begins.' }]);
const screen = new StoryScreen({ sequence, width: 960, height: 540 });
console.log(screen.current?.title); // 'Prologue'
StorySequence
class
The beats of a story screen and where the reader is in them, with no renderer attached - the same split TabbedList keeps, so the advance/back/skip rules are testable without a DOM.
import { StorySequence } from '@datamoc/mwg/two-d/stage';
const story = new StorySequence([
{ title: 'Prologue', text: 'The war begins.', music: 'intro.ogg' },
{ text: 'And so it went.' },
]);
story.onMusic.add((track) => console.log(track)); // 'intro.ogg', then null
story.advance();
console.log(story.done); // true, only one page was left
TabbedList
class
The model behind a tabbed, searchable, paged list - an inventory, journal, shop or codex - kept apart from how it is drawn, the same split SelectionModel already draws for plain lists. The caller supplies the tabs and the rows per tab ...
import { TabbedList } from '@datamoc/mwg/two-d/ui';
const items = [
{ name: 'Sword', kind: 'weapon' },
{ name: 'Bread', kind: 'food' },
{ name: 'Shield', kind: 'armor' },
];
const list = new TabbedList<(typeof items)[number]>({
tabs: [{ id: 'all', label: 'All' }, { id: 'weapon', label: 'Weapons' }],
...
TerrainGraphicsLayer
class
Draws the placements resolveTerrainGraphics resolves - the renderer half of [terrain_graphics] that the rule pass deliberately leaves to a caller.
import { TerrainGraphicsLayer, resolveTerrainGraphics } from '@datamoc/mwg/two-d/render';
const placements = resolveTerrainGraphics(2, 1, [], () => new Set(['land']));
const layer = new TerrainGraphicsLayer({
placements,
project: (x, y, dx, dy) => ({ x: (x + dx) * 32, y: (y + dy) * 32 }),
});
layer.setPlacements(placements);
Text2D
class
Text under an MWG-owned name, for the case ui.Label/ui.BitmapLabel do not cover - a one-off caption inside a Shape2D-drawn diagram, say, rather than themed game-wide UI text. Prefer Label/BitmapLabel for anything styled through ...
import { Text2D } from '@datamoc/mwg/two-d/render';
const caption = new Text2D({ text: 'N', style: { fill: 0xffffff, fontSize: 14 } });
caption.x = 40;
caption.y = 4;
TextModel
class
The editing state behind a text field, with no renderer attached - the counterpart to core.Input's onText, which supplies the characters: the DOM gives a key, this decides where it goes, and a game draws value (or maskedValue) with ...
import { TextModel } from '@datamoc/mwg/two-d/ui';
const name = new TextModel({ maxLength: 8, value: 'Hero' });
name.selectAll();
name.insert('Ash');
console.log(name.value); // 'Ash'
TextPrompt
class
A modal single-line text field: a name entry, a save-slot label, a seed.
import { TextPrompt } from '@datamoc/mwg/two-d/ui';
const prompt = new TextPrompt({
width: 320,
height: 140,
title: 'New hero',
message: 'Name your hero (Enter keeps it)',
maxLength: 12,
validate: (value) => (value.trim() === '' ? 'Give them a name first.' : null),
onConfirm: (value) => console.log('named', value),
...
Theme
interface
How the interface looks.
import { highContrastTheme, setTheme, theme, themeChanged } from '@datamoc/mwg/two-d/ui';
//a custom theme: only what differs from the default needs stating (`font` and `color` merge too)
setTheme({
padding: 12,
font: { family: 'Georgia, serif', size: 18, lineHeight: 1.4 },
color: { ...theme().color, text: 0xfff1d0, selection: 0x3a5a8a },
});
//widgets read the theme once, when built: one that must restyle later listens
...
TileMap
class
A grid of tiles, drawn as sprites.
import { TileMap, EMPTY, SpriteSheet, createCamera } from '@datamoc/mwg/two-d/render';
const sheet = SpriteSheet.grid('tiles/ground.png', 16);
const map = new TileMap({ width: 20, height: 20, sheet });
map.addLayer('ground', new Array(400).fill(0)); // frame 0 everywhere
map.addLayer('objects'); // an empty layer, filled in with setTile later
map.setTile('objects', 3, 3, 5);
console.log(map.getTile('objects', 3, 3)); // 5
...
TiledSprite
class
A texture that repeats/scrolls to fill a shape, rather than stretching - a parallax background, a scrolling water or lava tile. TilingSprite's own tilePosition/tileScale already covers the "scrolling tile layer" need in full; this is ...
import { TiledSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const waterTexture: Texture2D;
const water = new TiledSprite({ texture: waterTexture, width: 320, height: 96 });
water.tilePosition.x -= 12; // scroll it a little each frame, from update(dt)
TintedSprite
class
A sprite that can be pulled *towards* a colour, not just darkened.
import { TintedSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
const rat = new TintedSprite({ texture });
rat.lerpTint(0x00ff00, 0.5); // half-way to green: poisoned
rat.lerpTint(0xffffff, 0.8); // nearly white: just took a hit
rat.tint = 0x404060; // plain multiply: standing in the dark
rat.silhouette(0x000000); // a solid black shape, keeping only the sprite's outline
...
Toast
class
A queued, timed pop-up notification - an achievement unlock, a level-up banner, a "quest complete" toast. The same pop-in/hold/fade-out shape FloatingText already generalizes for a damage number, but queued rather than fire-and-forget: ...
import { Toast } from '@datamoc/mwg/two-d/ui';
import { Text2D } from '@datamoc/mwg/two-d/render';
const toast = new Toast({ fadeIn: 0.2, hold: 1.5, fadeOut: 0.5 });
toast.show(new Text2D({ text: 'Achievement unlocked: First Blood' }));
console.log(toast.isBusy); // true - popping in
toast.update(1 / 60); // called every frame from the scene's own update(dt)
Tooltip
class
The hover explanation every dense interface needs: what this item does, what this stat means, why this button is disabled.
import { Tooltip } from '@datamoc/mwg/two-d/ui';
const tooltip = new Tooltip({ delay: 0 });
tooltip.setViewport(800, 600);
tooltip.hover('Strength: raises attack', 120, 80);
// called every frame from the scene's own update(dt); returns true once, the frame it appears
console.log(tooltip.update(1 / 60)); // true - delay is 0, so it shows on the first frame
tooltip.leave(); // the pointer moved off the stat row
TooltipHost
class
Drives one Tooltip from rectangles instead of from each widget's own pointer events, which is what makes it work in the two places hover cannot reach: on a touch screen (a press that rests for longPress seconds shows the text, and ...
import { Button, TooltipHost } from '@datamoc/mwg/two-d/ui';
const host = new TooltipHost({ longPress: 0.4 });
host.tooltip.setViewport(800, 600);
const test = new Button({ width: 80, height: 28, text: 'Test', disabled: true,
tooltip: () => 'Build a road across the gap first' });
host.addButton(test);
host.pointerMove(10, 10); // over the disabled button: the text still shows after the hover delay
TransitionRunner
class
Plays one transition: owns the under and over layers, advances with update(dt) in real time and tells its onDone once. Game runs one of these around a scene change; it is exported so a game with its own compositing can run a ...
import { Container2D as Container } from '@datamoc/mwg/two-d/render';
import { TransitionRunner, dissolve } from '@datamoc/mwg/two-d/render';
const incoming = new Container();
const runner = new TransitionRunner({ transition: dissolve(), duration: 0.5 }, { width: 800, height: 600 }, null, incoming);
// put runner.under, incoming and runner.over on the stage, in that order
while (!runner.update(1 / 60)) continue;
console.log(runner.done); // true
TreeView
class
A collapsible tree, flattened to the rows a game draws - a skill tree, a nested options menu, a directory of scenarios grouped by campaign.
import { TreeView } from '@datamoc/mwg/two-d/ui';
const tree = new TreeView({
roots: [{ id: 'a', label: 'A', children: [{ id: 'a1', label: 'A1' }] }],
});
tree.expand('a');
console.log(tree.rows.map((row) => row.node.id)); // ['a', 'a1']
VerticalLabel
class
Text laid out top to bottom, column by column, right to left - vertical writing, which canvas has no writing-mode for at all. Each character is its own Text, positioned by layoutVertical; this is real per-glyph work, not a flag, which ...
import { VerticalLabel } from '@datamoc/mwg/two-d/ui';
const sign = new VerticalLabel({ text: '出口', columnHeight: 200, size: 24 });
sign.x = 400;
sign.y = 40;
Viewport
class
One player's own rectangle of the screen, for a same-screen local multiplayer layout: a Camera whose world is offset to start at x/y instead of the corner of the canvas, and clipped to width/height so it never bleeds into a ...
import { Viewport, splitScreenHalves } from '@datamoc/mwg/two-d/render';
const [left, right] = splitScreenHalves(640, 360);
const player1 = new Viewport({ ...left, zoom: 2 });
const player2 = new Viewport({ ...right, zoom: 2 });
// add both containers: game.stage.addChild(player1.container, player2.container);
player1.camera.follow({ x: 50, y: 50 });
player1.update(1 / 60);
Window
class
A panel with a frame, a title and a content area.
import { Window, Label } from '@datamoc/mwg/two-d/ui';
const confirm = new Window({ width: 200, height: 100, title: 'Leave?', anchor: 'center', blocker: true });
confirm.content.addChild(new Label({ text: 'Progress since your last save will be lost.' }));
confirm.onClose.add(() => console.log('window closed'));
confirm.place(800, 600); // centres it in an 800x600 viewport
confirm.handleAction('cancel'); // closable defaults to true, so this closes it
XP_AUTOTILE_PATTERNS
const
The standard XP autotile pattern table (48 patterns). An XP autotile image is a 96 by 128 template: a 6-column grid of 16px mini-blocks, and each baked pattern assembles four of them. tile % 48 selects the pattern; tile / 48 (floored) ...
import { XP_AUTOTILE_PATTERNS, XP_NEIGHBORS_TO_PATTERN } from '@datamoc/mwg/two-d/render';
console.log(XP_AUTOTILE_PATTERNS.length); // 48 - one entry per baked pattern
console.log(XP_AUTOTILE_PATTERNS[0]); // [26,27,32,33] - the filled interior tile
console.log(XP_NEIGHBORS_TO_PATTERN.length); // 256 - one entry per 8-neighbour mask
console.log(XP_NEIGHBORS_TO_PATTERN[255]); // 0 - surrounded on all sides is the interior
anchorAlign
function
The horizontal (0, 0.5, 1) and vertical alignment a named anchor implies.
import { anchorAlign } from '@datamoc/mwg/two-d/ui';
console.log(anchorAlign('bottom-right')); // { x: 1, y: 1 }
applyAllImageModifiers
function
The two-step recipe applyImageModifiers's own doc comment names but leaves a caller to drive by hand (item 310): bakes applyTextureModifiers's exact pixel-level result (~RC/~PAL/~BLIT/~MASK/~BLEND/~ROTATE) into ...
import { Sprite, Texture } from 'pixi.js';
import { applyAllImageModifiers, parseImagePath } from '@datamoc/mwg/two-d/render';
const sprite = new Sprite(Texture.EMPTY);
applyAllImageModifiers(sprite, parseImagePath('hero.png~BLEND(ff0000,50%)~FL(horizontal)'));
// sprite.texture carries the exact per-pixel blend; sprite.scale.x is flipped
applyImageModifiers
function
Apply the renderer-neutral subset supported by the Pixi backend.
import { Sprite } from 'pixi.js';
import { applyImageModifiers, parseImagePath } from '@datamoc/mwg/two-d/render';
applyImageModifiers(new Sprite(), parseImagePath('hero.png~FL(horizontal)'));
applyTextureModifiers
function
Applies the modifiers that need real pixel access or a sibling texture rather than a sprite property or a Pixi filter: ~RC (exact palette swap, src>dst hex or named-colour pairs - see resolveColor/parseColorPairs), ~PAL (the same ...
import { Texture } from 'pixi.js';
import { applyTextureModifiers, parseImagePath } from '@datamoc/mwg/two-d/render';
const recolored = applyTextureModifiers(Texture.EMPTY, parseImagePath('unit.png~RC(ff00ff>ff0000)'));
assertAutotileLayout
function
Checks a resolved autotile layout once, up front. Throws naming the first problem: a bad slot, an empty shape table, an empty animation cycle, a cycle kind outside its slot's kinds, a bad XP index, or a frame count below one.
import {
assertAutotileLayout,
RPGM_FLOOR_AUTOTILE_TABLE,
} from '@datamoc/mwg/two-d/render';
assertAutotileLayout({ format: 'rpgm-mv', slot: 0, table: RPGM_FLOOR_AUTOTILE_TABLE, cycles: [[0, 1, 2]] });
assertAutotileLayout({ format: 'rpgm-xp', index: 2, frames: 1, single: false });
autotileCellParts
function
Resolves one autotile cell to its drawn pieces: four quadrant halves for a template cell, one whole-tile piece for an XP single-tile strip, or null for a blank cell (tile <= 0 reads as blank; EMPTY is -1).
import {
autotileCellParts,
RPGM_FLOOR_AUTOTILE_TABLE,
} from '@datamoc/mwg/two-d/render';
const parts = autotileCellParts(
{ format: 'rpgm-mv', slot: 0, table: RPGM_FLOOR_AUTOTILE_TABLE, cycles: [] },
2048, 0,
);
console.log(parts?.length); // 4 - one piece per quadrant
...
autotileFrames
function
One frame per cell of a terrain, autotiled from a same-terrain test alone.
import { autotileFrames, BLOB_SHAPES } from '@datamoc/mwg/two-d/render';
const water = new Set([0, 1, 10, 11]); // a 2x2 pond in a wider grid
const frames = Array.from({ length: BLOB_SHAPES.length }, (_, index) => index); // frame N = shape N
const cellFrames = autotileFrames(10, 10, (x, y) => water.has(y * 10 + x), frames);
console.log(cellFrames[0]); // the blob frame for the pond's top-left corner
bitmapLabelStyle
function
the pure style-mapping BitmapLabel builds its underlying BitmapText style from, exported for testing without constructing a BitmapText (which needs a real DOM document, unlike Text)
import { bitmapLabelStyle } from '@datamoc/mwg/two-d/ui';
import { defaultTheme } from '@datamoc/mwg/two-d/ui';
const style = bitmapLabelStyle({ text: 'Score', bold: true }, defaultTheme);
console.log(style.fontWeight); // 'bold'
blendMatrix
function
A live, per-frame colour-matrix approximation of a blend towards color (0xRRGGBB) by ratio, the same shape TintedSprite.lerpTint gives an additive-capable sprite, expressed here as a plain matrix so a bare Pixi Sprite can use it ...
import { blendMatrix } from '@datamoc/mwg/two-d/render';
const matrix = blendMatrix(0xff0000, 0.5); // half-way to red
blendPixels
function
The renderer-free core of ~BLEND: every pixel's RGB is lerped ratio of the way towards color (0xRRGGBB), an exact per-pixel blend rather than blendMatrix's runtime approximation - this is what applyTextureModifiers bakes into the ...
import { blendPixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray([0, 0, 0, 255]); // one opaque black pixel
const out = blendPixels(pixels, 0xff0000, 0.5);
console.log([...out]); // [128, 0, 0, 255] - half-way to red
blobIndex
function
the blob index (0 to 46) for one cell's 8-neighbour membership test
import { blobIndex } from '@datamoc/mwg/two-d/render';
const surroundedOnAllSides = blobIndex({
n: true, e: true, s: true, w: true,
ne: true, se: true, sw: true, nw: true,
});
console.log(surroundedOnAllSides); // 46 - the fully-interior shape
channelScaleMatrix
function
Build the matrix for ~R/~G/~B(percent): scales one or more channels by a percentage (100 = unchanged), leaving any channel not passed at 100%.
import { channelScaleMatrix } from '@datamoc/mwg/two-d/render';
const matrix = channelScaleMatrix({ red: 150 }); // ~R(150), a 50% brighter red channel
console.log(matrix[0]); // 1.5
channelSwapMatrix
function
Build the matrix for ~CHAN(r,g,b,a): each output channel is fed by whichever named input channel (or constant black/white) is given for it, missing trailing entries left as the output channel's own identity source (R,G,B,A).
import { channelSwapMatrix } from '@datamoc/mwg/two-d/render';
const matrix = channelSwapMatrix(['B', 'G', 'R']); // swap the red and blue channels
colorShiftMatrix
function
Build the Pixi 5x4 colour-matrix for a Wesnoth CS(r,g,b) shift.
import { colorShiftMatrix } from '@datamoc/mwg/two-d/render';
const matrix = colorShiftMatrix(-20, 0, 30);
console.log(matrix.length); // 20
createCamera
function
a camera sized to the running game's viewport, updated on every frame
import { createCamera } from '@datamoc/mwg/two-d/render';
const camera = createCamera({ zoom: 2 }); // sized to Game.current automatically
createColorBlindnessFilter
function
A whole-scene filter simulating one of the three common types of colour vision deficiency. Assign the result to a container's .filters (typically a game's whole stage), the same way any Pixi filter is applied - nothing here is ...
import { createColorBlindnessFilter } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const stage: Container2D;
stage.filters = [createColorBlindnessFilter('deuteranopia')];
createLayers
function
Creates one labeled Node2D per name, attached to parent in the order given, and returns them keyed by name. A game with a dozen conventionally-ordered layers (terrain, units, effects, UI, ...) otherwise hand-wires each one and adds ...
import { Node2D, createLayers, Sprite2D } from '@datamoc/mwg/two-d/render';
const stage = new Node2D();
const layers = createLayers(stage, ['terrain', 'units', 'effects', 'ui']);
layers.units.addChild(new Sprite2D());
console.log(layers.effects.label); // 'effects'
croppedTexture
function
Make a cropped view without mutating the shared source texture.
import { Texture } from 'pixi.js';
import { croppedTexture, parseImagePath } from '@datamoc/mwg/two-d/render';
const cropped = croppedTexture(Texture.EMPTY, parseImagePath('atlas.png~CROP(0,0,16,16)'));
defaultTheme
const
The palette every widget starts with, before a game calls setTheme.
import { defaultTheme } from '@datamoc/mwg/two-d/ui';
console.log(defaultTheme.color.text); // 0xe8e8f0
console.log(defaultTheme.padding); // 8
detectWebGpu
function
The real, asynchronous WebGPU/WGSL check inspectGraphicsCapabilities cannot do synchronously: requests an actual adapter and device, then compiles an actual shader module and reads back its compilation diagnostics. Feed the result to ...
import { detectWebGpu, inspectGraphicsCapabilities } from '@datamoc/mwg/two-d/render';
const detected = await detectWebGpu();
const capabilities = inspectGraphicsCapabilities({ webgpu: detected.webgpu, wgsl: detected.wgsl });
console.log(capabilities.wgsl); // true only if a real WGSL shader actually compiled
dissolve
function
The old picture fading out over the new scene.
import { dissolve } from '@datamoc/mwg/two-d/render';
console.log(dissolve().name); // 'dissolve'
ellipsize
function
text cut to fit maxWidth, ending in ellipsis, as measured by measure (the width of a string in whatever font the caller draws it with). A text that already fits comes back unchanged.
import { ellipsize } from '@datamoc/mwg/two-d/ui';
const width = (text: string) => text.length * 10;
console.log(ellipsize('3. Le Fleuve profond (verrouillé)', 150, width)); // 3. Le Fleuve…
extractDialogueCatalog
function
Collects every say/ask line's text into an identity catalog - each key mapping to itself, the same shape MWL's own extractCatalog builds from _("...")-marked strings - so a translator has real entries to work from instead of a wall ...
import { parseDialogueText, extractDialogueCatalog } from '@datamoc/mwg/two-d';
const commands = parseDialogueText('@alice Hello.\n');
console.log(extractDialogueCatalog(commands).messages); // { 'Hello.': 'Hello.' }
fitWindowZoom
function
The zoom a window stack should run at: base, stepped down a whole number at a time until contentHeight (unzoomed) fits viewportHeight, and never below 1. A tall window on a short screen would otherwise be clipped at both ends.
import { fitWindowZoom } from '@datamoc/mwg/two-d/ui';
console.log(fitWindowZoom(3, 300, 700)); // 2 - 300 x 3 is too tall, 300 x 2 fits
floatingTextAgeAtLeast
function
The age a pop-up should report, given the age it has, its lifetime, and the age it is forced to be at least.
import { floatingTextAgeAtLeast } from '@datamoc/mwg/two-d/ui';
console.log(floatingTextAgeAtLeast(0, 1, 0.2)); // 0.2, a fresh line is pushed forward
console.log(floatingTextAgeAtLeast(0.5, 1, 0.2)); // 0.5, an older line keeps the age it has
floatingTextAlpha
function
Opacity at normalised progress t (0 to 1), with hold of that life spent at full opacity.
import { floatingTextAlpha } from '@datamoc/mwg/two-d/ui';
console.log(floatingTextAlpha(0.75, 0.5)); // 0.5
floatingTextRise
function
The rise at progress t, in pixels. Negative: pop-ups travel up the screen.
import { floatingTextRise } from '@datamoc/mwg/two-d/ui';
console.log(floatingTextRise(0.5, 24)); // -12
floatingTextStackMoves
function
The whole stacking policy as arithmetic: which of live (oldest first) share the newcomer's key, where each of them ends up, and what age each is forced to.
import {
FLOATING_TEXT_STACK_GAP,
floatingTextStackLift,
floatingTextStackLifePenalty,
floatingTextStackMoves,
} from '@datamoc/mwg/two-d/ui';
const older = { key: 'hero', x: 0, y: 0, height: 10 };
const newcomer = { key: 'hero', x: 0, y: 0, height: 8 };
...
flowLayout
function
Lays items out left to right and wraps to a new row when the next one would pass width: a toolbar that takes one row on a desktop and two on a phone. An item wider than width sits alone on its row rather than being dropped.
import { flowLayout } from '@datamoc/mwg/two-d/ui';
const buttons = [{ width: 120, height: 32 }, { width: 120, height: 32 }, { width: 120, height: 32 }];
const { positions, height } = flowLayout(buttons, 300, { gap: 8, align: 'center' });
console.log(positions.length, height); // 3 buttons on two rows: 72
glowFalloff
function
How much light a soft glow still carries at distance from its centre: 1 at the centre, easing to exactly 0 at radius, 0 past it. Pure, so a sign's falloff is checkable as arithmetic: the middle of a tube is always brighter than its rim.
import { glowFalloff } from '@datamoc/mwg/two-d/render';
console.log(glowFalloff(0, 24)); // 1 - the centre burns full
console.log(glowFalloff(24, 24)); // 0 - the rim carries nothing
console.log(glowFalloff(48, 24)); // 0 - past the rim stays dark
grow
function
The new scene growing from a point (the centre unless from names one) without overshoot.
import { grow } from '@datamoc/mwg/two-d/render';
console.log(grow().name); // 'grow'
hazeField
function
A deterministic wisp field in 0..1: two octaves of value noise over an integer lattice, smoothstep-interpolated so neighbouring samples always blend rather than step. Pure and seed-stable within a run: the same (x, y, time, seed) paints ...
import { hazeField } from '@datamoc/mwg/two-d/render';
console.log(hazeField(3, 5, 1.5, 7) === hazeField(3, 5, 1.5, 7)); // true - same sky twice
console.log(hazeField(3, 5, 1.5, 7) >= 0 && hazeField(3, 5, 1.5, 7) <= 1); // true - always a density
hexRotate
function
TerrainRotate for an axial hex grid: exact 60-degree steps (rotations must be a multiple of 6, or 0), via the standard cube-coordinate rotation (q,r,s -> -r,-s,-q per 60-degree step, s = -q - r).
import { hexRotate } from '@datamoc/mwg/two-d/render';
console.log(hexRotate(1, 0, 1, 6)); // one 60-degree step of the axial offset (1, 0)
highContrastTheme
const
A ready-made high-contrast preset: pure white text on black panels, a thicker border, and a near-opaque modal overlay. Not new capability - setTheme already replaces the whole palette a game reads from, so any high-contrast theme was ...
import { setTheme, highContrastTheme } from '@datamoc/mwg/two-d/ui';
setTheme(highContrastTheme); // an accessibility toggle in the settings screen
imageModifier
function
Return one named modifier from a parsed image path.
import { imageModifier, parseImagePath } from '@datamoc/mwg/two-d/render';
const flip = imageModifier(parseImagePath('hero.png~FL(horizontal)'), 'FL');
importTwee
function
Reads a .twee file into a StoryScript.
import { importTwee, StageScript } from '@datamoc/mwg/two-d';
declare const tweeSource: string;
declare const script: StageScript;
const story = importTwee(tweeSource);
await script.runStory(story.story, story.start);
inspectGraphicsCapabilities
function
Reports browser graphics APIs without creating a renderer or selecting a fallback. Pass a probe in tests or host integrations; omitting it reads the current browser safely.
import { inspectGraphicsCapabilities } from '@datamoc/mwg/two-d/render';
// reads the real browser when no probe is given
const here = inspectGraphicsCapabilities();
// a fake probe, for a test or a host integration with no DOM
const capabilities = inspectGraphicsCapabilities({
createCanvas: () => ({ getContext: (kind) => (kind === 'webgl2' ? {} : null) }),
webgpu: false,
});
...
iris
function
A circle opening from a point (the centre unless from names one) that shows the new scene through it, the old picture around it.
import { iris } from '@datamoc/mwg/two-d/render';
console.log(iris({ from: { x: 120, y: 80 } }).name); // 'iris'
isOnScreen
function
Whether a world-space point is on screen, for skipping animation work nobody sees.
import { Camera, isOnScreen } from '@datamoc/mwg/two-d/render';
declare const camera: Camera;
if (isOnScreen(camera, 160, 160)) console.log('the market square, animate it');
console.log(isOnScreen(camera, 10000, 10000, 32)); // far away, even with a margin
layoutMarkupLines
function
Wraps already-parsed spans into lines, word by word, each word measured under its own span's style - which is the acceptance this item was missing: wrapping computed *after* styling, not before it, so a bold or larger run wraps where its ...
import { layoutMarkupLines, parseMarkup } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup('Take <b>two small</b> coins');
const lines = layoutMarkupLines(spans, (piece) => piece.text.length * 8, 100);
console.log(lines.map((line) => line.spans.map((span) => span.text).join('')));
layoutVertical
function
The pure layout math behind VerticalLabel, kept separate from Pixi's Text so it can be tested without a canvas - Text needs a real document to measure glyphs, which does not exist outside a browser.
import { layoutVertical } from '@datamoc/mwg/two-d/ui';
const glyphs = layoutVertical('AB', { lineHeight: 10, columnHeight: 100 });
console.log(glyphs[0]); // { char: 'A', x: -5, y: 5, rotate: true } - Latin letters rotate by default
console.log(glyphs[1]); // { char: 'B', x: -5, y: 15, rotate: true }
loadSvgSkeleton
function
Load authored SVG limbs, normalize each around its pivot, and bind them to a skeleton. Uses SVG data-pivot-x / data-pivot-y attributes or pivots supplied by the game. Art is rotated back from its rest angle before rasterization, so rest ...
import { create } from '@datamoc/mwg/skeleton';
import { loadSvgSkeleton } from '@datamoc/mwg/two-d/render';
import { releaseSvgParts } from '@datamoc/mwg/assets';
const loaded = await loadSvgSkeleton('<svg xmlns="http://www.w3.org/2000/svg" width="30" height="10"><g id="arm" data-pivot-x="0" data-pivot-y="5"><rect width="30" height="10"/></g></svg>', {
skeleton: create([{ name: 'arm', length: 30, origin: { x: 0, y: 5 } }]), resolution: 2,
});
loaded.sprite.apply({ arm: 45 });
loaded.sprite.destroy({ children: true });
await releaseSvgParts(loaded.textures);
loadTiledMap
function
Builds a TileMap from parsed Tiled JSON and sprite sheets already cut to tile size.
import { loadTiledMap, SpriteSheet, type TiledMapData, type TiledTilesetData, type TilesetSheet } from '@datamoc/mwg/two-d/render';
import * as Resources from '@datamoc/mwg/assets';
const map: TiledMapData = Resources.get('maps/village.json');
const sheets: TilesetSheet[] = map.tilesets.map((ref) => {
if (ref.source === undefined) {
return { firstgid: ref.firstgid, sheet: SpriteSheet.grid('tiles/ground.png', map.tilewidth, map.tileheight) };
}
const tileset: TiledTilesetData = Resources.get(ref.source);
return { firstgid: ref.firstgid, sheet: SpriteSheet.grid(tileset.image, tileset.tilewidth, tileset.tileheight) };
...
markupAccessibilityText
function
The plain-text projection for a place that cannot render any markup at all - a screen reader, a log line, a tooltip that only takes a string. Unlike stripMarkup (which keeps an image span's raw path, useful for round-tripping but not for ...
import { markupAccessibilityText, parseMarkup } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup("Pay <b>10</b> gold<img>coin.png</img>");
console.log(markupAccessibilityText(spans)); // 'Pay 10 gold[image]'
console.log(markupAccessibilityText(spans, { describeImage: () => ' (coin icon)' }));
// 'Pay 10 gold (coin icon)'
marqueeOffset
function
How far a line of textWidth is shifted to scroll inside boxWidth, time seconds in: it waits pause seconds, slides to its end at speed px/s, waits again, then slides back, over and over. A text that fits never moves.
import { marqueeOffset } from '@datamoc/mwg/two-d/ui';
console.log(marqueeOffset(300, 200, 0)); // 0, waiting at the start
console.log(marqueeOffset(300, 200, 3)); // 100, at the far end
console.log(marqueeOffset(150, 200, 3)); // 0, it fits
maskPixels
function
The renderer-free core of ~MASK: base's alpha channel is multiplied by mask's alpha at the given offset (a pixel the mask does not cover keeps zero alpha), base's own colour left untouched. Exported so the masking logic is testable ...
import { maskPixels } from '@datamoc/mwg/two-d/render';
const base = new Uint8ClampedArray([10, 20, 30, 255]); // one opaque pixel
const mask = new Uint8ClampedArray([0, 0, 0, 128]); // half-alpha mask, same 1x1 size
const out = maskPixels(base, 1, 1, mask, 1, 1, 0, 0);
console.log([...out]); // [10, 20, 30, 128]
matchTerrainRule
function
Whether rule, rotated by rotationIndex of rotations steps, matches the cell at x, y.
import { matchTerrainRule } from '@datamoc/mwg/two-d/render';
const rule = { id: 'coast', conditions: [{ dx: 1, dy: 0, hasAll: ['water'] }], images: [] };
const flagsAt = (x: number, y: number) => (x === 1 && y === 0 ? new Set(['water']) : new Set(['land']));
console.log(matchTerrainRule(rule, 0, 0, flagsAt)); // true
minimapCellCenter
function
The minimap centre for a cell, useful for overlays that must share its topology.
import { minimapCellCenter } from '@datamoc/mwg/two-d/render';
const centre = minimapCellCenter(3, 2, 4, 'hex');
newlyRevealed
function
the cell indices in explored not yet baked into a minimap's texture - pure, so it can be tested without a renderer
import { newlyRevealed } from '@datamoc/mwg/two-d/render';
const explored = new Set([4, 9, 12]);
const alreadyDrawn = new Set([4]);
console.log(newlyRevealed(explored, alreadyDrawn)); // [9, 12]
openAnimationFrame
function
What a window opening (or closing, run backwards) looks like at progress: its scale, opacity and how far it is still pushed down by a slide. Closing never overshoots.
import { openAnimationFrame } from '@datamoc/mwg/two-d/render';
console.log(openAnimationFrame('pop', 0).scale); // 0
console.log(openAnimationFrame('pop', 1).scale); // 1
console.log(openAnimationFrame('slide', 0.5).offset > 0); // true: still sliding up
packColorAdd
function
Packs an additive colour into the form the attribute expects.
import { packColorAdd } from '@datamoc/mwg/two-d/render';
import { TintedSprite } from '@datamoc/mwg/two-d/render';
declare const rat: TintedSprite;
rat.colorAdd = packColorAdd(0, 1, 0); // pull the sprite towards pure green
packTintAdd
function
the additive half of lerp(texel, colour, strength); pair with a tint of 1 - strength
import { packTintAdd, TintedSprite } from '@datamoc/mwg/two-d/render';
declare const rat: TintedSprite;
rat.colorAdd = packTintAdd(0x00ff00, 0.5); // half-way to green, the additive half of lerpTint
rat.tint = 0x808080; // pair with 1 - strength (here 0.5) for the multiply half
paintFogPixels
function
Paints per-cell fog into an RGBA pixel buffer of width * resolution by height * resolution pixels. Pure: no canvas involved, so the shading rule is checkable anywhere. A state with no palette entry paints transparent.
import { paintFogPixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray(2 * 1 * 4);
paintFogPixels(pixels, 2, 1, 1, (x) => x, [[0, 0, 0, 0], [0, 0, 0, 255]]);
console.log(pixels[7]); // 255 - the second cell is fully fogged
paintGlowPixels
function
Paint a size * size radial glow (the sprite is square, size is odd) into pixels as RGBA bytes. Centre pixels carry the colour at full intensity, rim pixels fade to transparent, and intensity scales every channel linearly, so doubling ...
import { paintGlowPixels } from '@datamoc/mwg/two-d/render';
const size = 5;
const pixels = new Uint8ClampedArray(size * size * 4);
paintGlowPixels(pixels, size, { color: 0xff0000, radius: 2, intensity: 1 });
console.log(pixels[0] > pixels[pixels.length - 4]); // true - the centre burns redder than the rim
paintHazePixels
function
Paint a white haze sheet with per-pixel alpha into pixels (width * height RGBA). The layer tints the sprite itself, so one buffer serves any colour. Pure, like paintFogPixels: density 0 paints fully transparent, and raising the ...
import { paintHazePixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray(4 * 2 * 4);
paintHazePixels(pixels, 4, 2, { time: 0, density: 1, seed: 3 });
console.log(pixels.some((v, i) => i % 4 === 3 && v > 0)); // true - full density always shows some haze
paletteRangeMapping
function
Builds a PaletteMapping that recolours every entry of reference into a shade of the min -> mid -> max gradient, the shape a team colour or any other [color_range]-style remap is defined by. reference is the exact list of colours ...
import { paletteRangeMapping, recolorTexture } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
declare const magentaReference: readonly number[]; // the exact colours the art was painted with
const redTeam = paletteRangeMapping(magentaReference, { min: 0x000000, mid: 0xff0000, max: 0xffffff });
const recolored = recolorTexture(texture, redTeam); // 'exact': only those pixels are repainted
parseColorPairs
function
~RC(src>dst,src>dst,...)'s pairs, each side hex (#c0ffee, c0ffee) or, when resolve is given, a named colour (~RC(magenta>red)) it resolves. A pair whose either side is neither hex nor resolvable is dropped rather than aborting ...
import { parseColorPairs, parseImagePath, imageModifier } from '@datamoc/mwg/two-d/render';
const rc = imageModifier(parseImagePath('unit.png~RC(magenta>ff0000)'), 'RC')!;
const mapping = parseColorPairs(rc.args, (name) => (name === 'magenta' ? 0xff00ff : undefined));
console.log(mapping); // { from: [0xff00ff], to: [0xff0000] }
parseDialogueText
function
A terse, line-based text format for straight (non-branching) dialogue - the counterpart to twee.ts's passage graphs, for the common case of a scene that is just a sequence of lines. rpg has its own wrapper around the same parsing ...
import { parseDialogueText, StageScript } from '@datamoc/mwg/two-d';
declare const script: StageScript;
const commands = parseDialogueText(`
@alice Hello.
@bob Hi!
- How are you?
- Fine, and you?
`);
...
parseImagePath
function
Split top-level modifiers while keeping nested ~ paths inside arguments.
import { parseImagePath } from '@datamoc/mwg/two-d/render';
const parsed = parseImagePath('hero.png~FL(horizontal)~GS');
parseMarkup
function
Reads source as inline markup and returns one span per styled run.
import { escapeHtml, markupToHtml, parseMarkup, stripMarkup } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup("Hail, <b>$name</b>! <span color='#c0ffee'>Four</span> coins<br/>And an<img>gold.png</img>", {
variables: { name: 'Kalenz' },
});
console.log(spans[1]); // { text: 'Kalenz', bold: true, italic: false }
console.log(spans[2]); // { text: 'Four', bold: false, italic: false, color: '#c0ffee' }
console.log(spans[3].text); // '\nAnd an'
console.log(spans[4].image); // 'gold.png'
...
parsePaletteLists
function
~PAL(a,b,c>x,y,z)'s two comma-separated colour lists, one per side of a single >. The modifier's own args have already been comma-split by parseImagePath before this runs (the same naive split every modifier's argument list goes ...
import { parsePaletteLists, parseImagePath, imageModifier } from '@datamoc/mwg/two-d/render';
const pal = imageModifier(parseImagePath('unit.png~PAL(ff0000,00ff00>0000ff,ffff00)'), 'PAL')!;
console.log(parsePaletteLists(pal.args)); // { from: [0xff0000, 0x00ff00], to: [0x0000ff, 0xffff00] }
parseRotateMode
function
The sampling ~ROTATE(degrees, mode) asks for, from its second argument: linear (or smooth, since either word is what an author reaches for) interpolates, anything else - including nothing at all - takes the fast nearest-neighbour ...
import { parseImagePath, imageModifier, parseRotateMode } from '@datamoc/mwg/two-d/render';
const rotate = imageModifier(parseImagePath('tile.png~ROTATE(60,linear)'), 'ROTATE');
parseRotateMode(rotate?.args[1]); // 'linear'
parseRotateMode(undefined); // 'nearest'
pop
function
The new scene springing out of a point (the centre unless from names one), overshooting a little (overshoot, 1.08 when omitted) before it settles.
import { pop } from '@datamoc/mwg/two-d/render';
console.log(pop({ from: { x: 400, y: 300 } }).name); // 'pop'
popScale
function
The scale of something that pops in: 0 growing past 1 to overshoot, then settling on 1.
import { popScale } from '@datamoc/mwg/two-d/render';
console.log(popScale(0)); // 0
console.log(popScale(0.7)); // 1.08, the peak
console.log(popScale(1)); // 1
positionMarkupLines
function
Positions already-laid-out lines into a flat list of placed runs, one per span, so a canvas backend draws each span as its own Text2D (or an image as its own sprite) at the reported x/y. This is the half layoutMarkupLines ...
import { layoutMarkupLines, parseMarkup, positionMarkupLines } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup('Take <b>two</b> coins<img>coin.png</img>');
const measure = (piece: { text: string; image?: string }) =>
piece.image !== undefined ? 16 : piece.text.length * 8;
const lines = layoutMarkupLines(spans, measure, 120);
const runs = positionMarkupLines(lines, { measure, maxWidth: 120, lineHeight: 16 });
console.log(runs.map((run) => run.span.image ?? run.span.text).join(''));
recolorTexture
function
Recolours a texture by palette remap: draws it to a canvas, runs remapPixels over the raw pixel data, and returns a new Texture wrapping the result. The source texture is never mutated, so the same base art recolours per team without ...
import { paletteRangeMapping, recolorTexture } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
const mapping = paletteRangeMapping([0xffffff, 0x808080, 0x000000], { min: 0xffe0e0, mid: 0xc00000, max: 0x400000 });
const redTeam = recolorTexture(texture, mapping, {}, 'nearest');
rectOf
function
Converts a pixi.js Rectangle to the plain Rect shape public APIs use. Internal to two-d; a game never constructs a pixi.js Rectangle to call this.
import { rectOf, type Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
const frame = rectOf(texture.frame);
console.log(frame); // { x: 0, y: 0, width: 32, height: 32 }, or whatever the texture was cut to
registerColorTransform
function
Registers the colour-transform batcher and the TintedSprite pipe with Pixi.
import { registerColorTransform } from '@datamoc/mwg/two-d/render';
// a game that assembles its own Pixi renderer extensions manually, instead of going
// through `Game`/`GameOptions.extensions`, registers the batcher itself before creating
// that renderer - importing `TintedSprite` (directly or transitively) already does this,
// so most games never call it
registerColorTransform();
remapPixels
function
The renderer-free core of a palette remap. In 'exact' mode (the default) a pixel is repainted only when it exactly matches one of mapping.from; every other pixel, including one close but not identical to a from colour, passes through ...
import { remapPixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray([255, 0, 255, 255, 10, 10, 10, 255]); // magenta, then dark grey
const out = remapPixels(pixels, { from: [0xff00ff], to: [0xff0000] }); // 'exact' by default
console.log([...out.slice(0, 4)]); // [255, 0, 0, 255] - the exact match repainted
console.log([...out.slice(4, 8)]); // [10, 10, 10, 255] - not a match, left alone
resolveAnchor
function
Places a size-shaped child against the bounds - the anchor half of a data-driven shell, the part GUI2 spells as [cell] anchoring a widget inside its slot.
import { resolveAnchor } from '@datamoc/mwg/two-d/ui';
const bounds = { x: 0, y: 0, width: 800, height: 600 };
console.log(resolveAnchor({ anchor: 'bottom-right', width: 100, height: 30, margin: 10 }, bounds));
// { x: 690, y: 560, width: 100, height: 30 }
resolveEasing
function
the Easing function for a name or a function, defaulting to easeInOutQuad
import { resolveEasing } from '@datamoc/mwg/two-d/render';
console.log(resolveEasing('linear')(0.25)); // 0.25
console.log(resolveEasing((t) => t * t)(0.5)); // 0.25
resolveTerrainGraphics
function
Resolves a [terrain_graphics]-style rule set over a grid into the flat list of image placements a renderer draws, the generalisation Autotile's 47-shape blob table cannot express: a rule can test flags rather than only "same terrain", ...
import { resolveTerrainGraphics } from '@datamoc/mwg/two-d/render';
const water = new Set(['water']);
const land = new Set(['land']);
const flagsAt = (x: number, y: number) => (x === 0 ? water : land);
const placements = resolveTerrainGraphics(2, 1, [
{ id: 'coast', conditions: [{ dx: 0, dy: 0, hasAll: ['land'] }, { dx: -1, dy: 0, hasAll: ['water'] }], images: [{ image: 'coast.png' }] },
], flagsAt);
console.log(placements); // [{ x: 1, y: 0, ruleId: 'coast', image: 'coast.png', dx: 0, dy: 0, layer: 0 }]
rotatePixels
function
The renderer-free core of ~ROTATE: rotates the source pixels themselves by degrees (clockwise) around their centre and expands the surface to fit the rotated bounds, rather than turning a sprite's own transform - ...
import { rotatePixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray([255, 0, 0, 255, 0, 255, 0, 255]); // 2x1: red, green
const rotated = rotatePixels(pixels, 2, 1, 90);
console.log(rotated.width, rotated.height); // 1, 2 - the surface expanded to fit
const smooth = rotatePixels(pixels, 2, 1, 60, 'linear'); // the same, with an interpolated edge
rpgmAutotileFrame
function
Describes how RPG Maker MV composes one autotile from four 24px atlas quadrants.
import { rpgmAutotileFrame } from '@datamoc/mwg/two-d/render';
const shapeTable = Array.from({ length: 48 }, () => [[0, 0], [1, 0], [0, 1], [1, 1]] as const);
const frame = rpgmAutotileFrame(7, 0, 3, shapeTable);
console.log(frame.quadrants[0].sourceX); // source pixel x coordinate
rpgmAutotileSlot
function
Which MV autotile family a raw map tile id belongs to, or null when the id is not an autotile at all (an EMPTY cell, a static B-E tile, an A5 tile).
import {
rpgmAutotileSlot,
RPGM_AUTOTILE_SLOT_BASES,
RPGM_AUTOTILE_SLOT_COUNTS,
} from '@datamoc/mwg/two-d/render';
console.log(rpgmAutotileSlot(2048)); // 0 - the first A1 tile
console.log(rpgmAutotileSlot(3000)); // 1 - inside the A2 range
console.log(rpgmAutotileSlot(42)); // null - a static tile, not an autotile
console.log(rpgmAutotileSlot(-1)); // null - EMPTY reads as blank, never as a slot
...
rpgmTableEdgeCells
function
The cells that get a table's front-edge strip, in the engine's own rule (Tilemap._paintTiles): a cell whose layer-1 neighbour above is an A2 table tile, while its own layer-1 tile is not a table, and whose layer-0 tile is not an A3/A4 ...
import { rpgmTableEdgeCells } from '@datamoc/mwg/two-d/render';
const table = 2816 + 48 * 0 + 47; // an A2 kind with the table flag
const flags = { [table]: 0x80 };
const cells = rpgmTableEdgeCells({ width: 1, height: 2, ground: [0, 0], objects: [table, 0], flags });
console.log(cells[1]); // the table tile above cell 1
console.log(cells[0]); // 0 - nothing above the top row
scrollOffset
function
Clamps a scroll offset so the content never pulls away from the top or leaves a gap past the bottom. When the content is shorter than the viewport the only offset is 0.
import { scrollOffset } from '@datamoc/mwg/two-d/ui';
console.log(scrollOffset(-5, 500, 200)); // 0
console.log(scrollOffset(900, 500, 200)); // 300
setTheme
function
Replaces the theme; partial objects are merged over the default.
import { setTheme, theme } from '@datamoc/mwg/two-d/ui';
setTheme({ color: { ...theme().color, textHighlight: 0x66ff66 } });
console.log(theme().color.textHighlight); // 0x66ff66
console.log(theme().padding); // 8 - everything else stays at the default
sharpenText
function
Re-rasterises every Text under root at devicePixelRatio times its own on-screen scale, rounded to a whole number. Pixi draws text at the renderer's resolution and knows nothing of a parent's zoom, so text inside a scaled container is ...
import { Container } from 'pixi.js';
import { sharpenText } from '@datamoc/mwg/two-d/ui';
const panel = new Container();
panel.scale.set(3);
sharpenText(panel, 2); // every Text inside now rasterises at 6x
shrink
function
The old picture shrinking to a point (the screen's centre unless to names one) over the new scene.
import { shrink } from '@datamoc/mwg/two-d/render';
const closeToButton = shrink({ to: { x: 700, y: 40 } });
console.log(closeToButton.name); // 'shrink'
sliceSpans
function
The first count visible characters across already-parsed spans, styles kept - the progressive-reveal slice for RichLabel, where markers must never count toward the total nor leak onto screen half-shown. Counts in string characters, the ...
import { parseMarkdown, sliceSpans } from '@datamoc/mwg/two-d/ui';
sliceSpans(parseMarkdown('Take **two** coins'), 7);
// [{ text: 'Take ', bold: false, italic: false },
// { text: 'tw', bold: true, italic: false }]
slide
function
Old and new pictures sliding together: the new one enters from the direction side while the old one leaves the other way.
import { slide } from '@datamoc/mwg/two-d/render';
console.log(slide({ direction: 'left' }).name); // 'slide'
sliderFraction
function
Where value sits in [min, max], 0 to 1, clamped. A degenerate range reads 0 rather than dividing by zero.
import { sliderFraction } from '@datamoc/mwg/two-d/ui';
console.log(sliderFraction(30, 0, 60)); // 0.5
sliderValueAt
function
The value at a 0-to-1 position on the track, snapped to step and clamped to [min, max]. The snap is rounded to the step's own number of decimals, so a 0.1 step reads 0.3 rather than 0.30000000000000004.
import { sliderValueAt } from '@datamoc/mwg/two-d/ui';
console.log(sliderValueAt(0.5, 0, 10, 1)); // 5
snapZoom
function
The nearest zoom to zoom at which tileSize * zoom is a whole number of device pixels (resolution device pixels per screen pixel, default 1) - the pure math behind CameraOptions.pixelPerfectTileSize, exported so a game can snap a ...
import { snapZoom } from '@datamoc/mwg/two-d/render';
console.log(snapZoom(2.4, 16)); // 2.375 - 16 * 2.375 = 38, a whole number of pixels
spinValue
function
Steps a number by delta, snapped to step and kept in [min, max]. With wrap, passing the top starts again at the bottom and dropping below the bottom lands on the top; without it, the value clamps - a level cap or a bag size, where ...
import { spinValue } from '@datamoc/mwg/two-d/ui';
console.log(spinValue(10, 1, 0, 10, 1)); // 10, clamped
console.log(spinValue(10, 1, 0, 10, 1, true)); // 0, wrapped
splitScreenHalves
function
The two even halves of width×height a two-player split screen typically wants - side by side for a landscape screen, stacked for a portrait one - as plain rectangles a game hands straight to two Viewports (or to resize on a resize ...
import { splitScreenHalves } from '@datamoc/mwg/two-d/render';
const [top, bottom] = splitScreenHalves(400, 600); // taller than wide: stacks vertically
console.log(top); // { x: 0, y: 0, width: 400, height: 300 }
console.log(bottom); // { x: 0, y: 300, width: 400, height: 300 }
spriteColorMatrix
function
Attaches matrix to sprite as a ColorMatrixFilter, so a caller using colorShiftMatrix/channelScaleMatrix/blendMatrix/channelSwapMatrix never has to name pixi.js itself just to construct the one filter class those matrices ...
import { Sprite } from 'pixi.js';
import { blendMatrix, spriteColorMatrix } from '@datamoc/mwg/two-d/render';
const sprite = new Sprite();
spriteColorMatrix(sprite, blendMatrix(0xff0000, 0.5)); // half-blended red, no pixi.js import needed for the filter itself
squareRotate
function
TerrainRotate for a square or isometric grid: exact 90-degree steps only (rotations must be a multiple of 4, or 0).
import { squareRotate } from '@datamoc/mwg/two-d/render';
console.log(squareRotate(1, 0, 1, 4)); // { dx: 0, dy: 1 } - one 90-degree step
takeLastEntries
function
The newest count entries of a retained log, oldest first. Pure so the readback contract is testable without a renderer: a count of 0 (or negative) reads nothing, a `count past what is retained reads everything retained.
import { takeLastEntries } from '@datamoc/mwg/two-d/ui';
console.log(takeLastEntries(['a', 'b', 'c'], 2)); // ['b', 'c']
theme
function
import { theme } from '@datamoc/mwg/two-d/ui';
console.log(theme().color.text); // 0xe8e8f0 - the default, until a game calls setTheme
themeChanged
const
Fired with the new theme every time setTheme runs.
import { themeChanged } from '@datamoc/mwg/two-d/ui';
themeChanged.add((next) => console.log('theme changed, text colour is now', next.color.text));
tileDelays
function
When each tile of a columns by rows grid starts, from 0 (first) to 1 (last), in row-major order. diagonal sweeps from the top left, spiral from the outside in, rows top to bottom, and random is a fixed shuffle for a given ...
import { tileDelays } from '@datamoc/mwg/two-d/render';
console.log(tileDelays(3, 2, 'diagonal')); // [0, 1/3, 2/3, 1/3, 2/3, 1]
tileFrame
function
Packs a (sheet, frame) pair into one cell value, for a TileMap built over several sheets - a map whose tiles come from more than one Tiled tileset.
import { tileFrame, tileFrameSheet, tileFrameIndex } from '@datamoc/mwg/two-d/render';
const packed = tileFrame(1, 42); // frame 42 of the second sheet (index 1)
console.log(tileFrameSheet(packed)); // 1
console.log(tileFrameIndex(packed)); // 42
tileFrameIndex
function
The frame half of a tileFrame pack, or the value itself for a plain index.
import { tileFrame, tileFrameIndex } from '@datamoc/mwg/two-d/render';
console.log(tileFrameIndex(tileFrame(2, 5))); // 5
console.log(tileFrameIndex(5)); // 5 - a plain frame index passes through unchanged
tileFrameSheet
function
The sheet half of a tileFrame pack, or 0 for a plain frame index.
import { tileFrame, tileFrameSheet } from '@datamoc/mwg/two-d/render';
console.log(tileFrameSheet(tileFrame(2, 5))); // 2
console.log(tileFrameSheet(5)); // 0 - a plain frame index reads as sheet 0
tileProgress
function
One tile's progress when the whole transition is at progress: tiles start spread across the first stagger of the time, according to their delay from tileDelays, and each then takes the rest of it, so the last tile finishes exactly ...
import { tileProgress } from '@datamoc/mwg/two-d/render';
console.log(tileProgress(0, 0.5, 0.5)); // 1: the first tile is already done
console.log(tileProgress(1, 0.5, 0.5)); // 0: the last one has not started
tileShrink
function
The old picture cut into tiles that each shrink to nothing, in order, uncovering the new scene.
import { tileShrink } from '@datamoc/mwg/two-d/render';
const transition = tileShrink({ columns: 8, rows: 5, order: 'diagonal' });
// Game.current.switchScene(NextLevel, { transition, duration: 0.6 });
console.log(transition.name); // 'tileShrink'
transitionDisposition
function
The rule for a request that arrives while a transition is playing: it applies at once when nothing is playing; while one is playing, a request with no transition finishes it and applies (interrupt, the historical behaviour), a transition ...
import { transitionDisposition, dissolve } from '@datamoc/mwg/two-d/render';
console.log(transitionDisposition({ transition: dissolve() }, true)); // 'ignore'
console.log(transitionDisposition({ transition: dissolve(), queue: true }, true)); // 'queue'
console.log(transitionDisposition({ transition: dissolve() }, false)); // 'apply'
truncateLines
function
text wrapped at maxWidth and cut to maxLines, the last kept line ending in an ellipsis when something was dropped. Words are not split unless one alone is wider than a line.
import { truncateLines } from '@datamoc/mwg/two-d/ui';
const width = (text: string) => text.length * 10;
console.log(truncateLines('build a bridge across the river gorge', 120, 2, width));
// ['build a', 'bridge acr…']
withTextureCanvas
function
Opens a canvas the size of texture, draws it in, and hands the 2D context to paint to make whatever further edits it needs (a palette remap, a composited overlay, a rewritten alpha channel); the result becomes the returned Texture. ...
import { withTextureCanvas } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
// a minimal custom modifier: invert every pixel's RGB, alpha untouched
const inverted = withTextureCanvas(texture, {}, (context, width, height) => {
const { data } = context.getImageData(0, 0, width, height);
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
...
xpAutotilePattern
function
Which XP pattern a cell's 8-neighbourhood bakes to, for maps computed procedurally rather than read from baked data. sameTerrain is asked once per direction, with (dx, dy) offsets ((0, -1) is north); off the map is the caller's to ...
import { xpAutotilePattern } from '@datamoc/mwg/two-d/render';
const filled = new Set(['0,0', '1,0', '0,1', '1,1', '-1,0', '0,-1', '2,1', '1,2']);
console.log(xpAutotilePattern((dx, dy) => filled.has(`${dx},${dy}`))); // 0 - the interior
console.log(xpAutotilePattern(() => false)); // 46 - isolated on all sides
xpAutotileRef
function
Decodes a raw XP map cell into its autotile image and pattern, or null when the cell holds no autotile reference: EMPTY, a static tileset tile (384 and up), or anything outside the 1-383 autotile range.
import { xpAutotileRef } from '@datamoc/mwg/two-d/render';
console.log(xpAutotileRef(97)); // { index: 2, pattern: 1 } - second image, second pattern
console.log(xpAutotileRef(0)); // null - EMPTY is blank, never an autotile
console.log(xpAutotileRef(384)); // null - the first static tileset tile
two-d/pixi-interop
import { registerBuiltinPipes, TilingSprite } from '@datamoc/mwg/two-d/pixi-interop';
// only needed if a game's own bundler configuration tree-shook TilingSpritePipe/
// NineSliceSpritePipe away; call before the renderer is created
registerBuiltinPipes();
const backdrop = new TilingSprite({ texture: undefined as never, width: 100, height: 100 });
two-d/presentation
MwlPresentation
class
Instantiates compiled MWL presentation using existing Pixi widgets. The controller owns state and actions; context switches restyle the same views without rebuilding procedural adapters. Kept off the root barrel so a game pays for this ...
import { MwlPresentation } from '@datamoc/mwg/two-d/presentation';
import type { CompiledPresentation } from '@datamoc/mwg/mwl/presentation';
import { Node2D } from '@datamoc/mwg/two-d/render';
declare const data: CompiledPresentation;
const ui = new MwlPresentation(data, 'game', { parent: new Node2D(), state: () => ({}), actions: {} });
ui.resize(800, 600);
ui.update(1 / 60);
ui.destroy();
two-d/render
- battle
- bridge
- chess
- cloth
- colour-transform
- dialogue
- dungeon
- hello-world
- interface
- minigame
- movement
- mwl-presentation
- neural
- pacman
- spring
- tower-defense
- village
- world-transition
ActorAnimator
class
The convention AnimatedSprite and GridMover never had: standing still, moving, and performing an action are the only three states a character's animation is ever in, and an action is the only one of the three that interrupts the other ...
import { ActorAnimator, AnimatedSprite, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
const hero = new AnimatedSprite();
hero.add('idle-down', sheet.pick(0), { fps: 2 });
hero.add('move-down', sheet.range(1, 4), { fps: 10 });
hero.add('action-down', sheet.range(5, 7), { fps: 12, loop: false });
const animator = new ActorAnimator(hero, {
...
ActorAnimatorOptions
interface
import type { ActorAnimatorOptions } from '@datamoc/mwg/two-d/render';
const options: ActorAnimatorOptions = {
animationName: (state, facing) => `${state}-${facing}`,
variant: 'down',
};
AnimatedSprite
class
A sprite that plays named animations, and can still be tinted.
import { AnimatedSprite, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
const hero = new AnimatedSprite();
hero.add('idle', sheet.pick(0, 0, 0, 1), { fps: 2 });
hero.add('walk', sheet.range(6, 10), { fps: 10 });
hero.add('die', sheet.range(11, 14), { fps: 10, loop: false });
hero.add(
'thrust',
...
Animation
class
import { Animation } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const frames: Texture2D[];
const walk = new Animation(frames, { fps: 10 });
console.log(walk.duration); // frames.length / 10, in seconds
ArticulatedChain
class
A serpent's spine as pure positions.
import { ArticulatedChain } from '@datamoc/mwg/two-d/render';
const spine = new ArticulatedChain({ segments: 8, length: 12, bendLimit: 45 });
spine.pull(100, 0);
spine.update(1); // the body starts following the head
BLOB_SHAPES
const
Stitching a terrain edge or corner from many small tile pieces, chosen by which neighbours are the same terrain, rather than a game picking a frame per cell by hand.
import { BLOB_SHAPES } from '@datamoc/mwg/two-d/render';
console.log(BLOB_SHAPES.length); // 47
console.log(BLOB_SHAPES[0]); // { n: false, e: false, s: false, w: false, ne: false, se: false, sw: false, nw: false }
Beam
class
A one-shot beam between two screen points: a fading line by default, a fading texture strip with texture - tiled, or stretched once along the span in the object form, with thin narrowing the body as it fades (see BeamOptions). The ...
import { Beam } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const rayTexture: Texture2D;
const zap = new Beam({ x: 0, y: 0 }, { x: 100, y: 0 }, { colour: 0x88ccff, duration: 0.2, thin: true });
const ray = new Beam({ x: 0, y: 0 }, { x: 100, y: 0 }, {
texture: { source: rayTexture, stretch: true },
width: 8,
thin: true,
...
Beams
class
Owns the beams a game has flashing: one add per ray, one update(dt) fading them all and forgetting the finished ones, and no bookkeeping left to the caller. Beams are parented here and destroyed with their beam; z-order stays the ...
import { Beams } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const layer: Container2D;
const beams = new Beams();
layer.addChild(beams);
beams.add({ x: 0, y: 0 }, { x: 100, y: 0 }, { colour: 0x88ccff });
beams.update(1 / 60);
...
COLOR_BLINDNESS_MATRICES
const
Brettel/Viénot-derived simulation matrices, the same values widely used by colourblindness simulators - a 5x4 (20-value) row-major RGBA matrix, Pixi's own ColorMatrixFilter format. Exported so the matrix data itself can be tested without ...
import { COLOR_BLINDNESS_MATRICES } from '@datamoc/mwg/two-d/render';
console.log(COLOR_BLINDNESS_MATRICES.deuteranopia.length); // 20
Camera
class
import { Camera } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const stage: Container2D;
const camera = new Camera({ zoom: 2 });
camera.setViewport(960, 540);
stage.addChild(camera.world);
camera.snapTo(160, 160); // jump to the start room, no easing
...
CameraOptions
interface
A view onto a world larger than the screen.
import { Camera, createCamera } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const stage: Container2D;
declare const hero: { x: number; y: number };
const camera = createCamera({ zoom: 3, deadzone: 0.3, pixelPerfectTileSize: 16 });
stage.addChild(camera.world);
camera.follow(hero);
...
ChainRig
class
The drawing half of a chain: one sprite per segment.
import { Texture, TextureSource } from 'pixi.js';
import { ArticulatedChain, ChainRig } from '@datamoc/mwg/two-d/render';
const skin = new Texture({ source: new TextureSource({ width: 8, height: 8 }) });
const spine = new ArticulatedChain({ segments: 3, length: 10 });
const rig = new ChainRig(spine, [skin, skin, skin]); // one Texture per segment
rig.update(1); // segments sit on the chain's points
ClothSprite
class
A garment that follows with a slight lag: a Cloth simulation driving a textured mesh on the GPU path 414 ships (roadmap 423). The cloth spans the texture, the top edge starts pinned (a waistband, a held scarf end), and update(dt) steps ...
import { ClothSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const tunicTexture: Texture2D;
const tunic = new ClothSprite(tunicTexture, { segmentsX: 6, segmentsY: 8, gravity: { x: 0, y: 900 } });
tunic.update(1 / 60); // the hem answers gravity, the waistband stays pinned
console.log(tunic.movedCount()); // > 0 - everything below the pinned edge travelled
ColorTransformBatcher
class
The Pixi batcher extension TintedSprite (and everything built on it) renders through - see the module doc comment above for what it adds and why.
import { ColorTransformBatcher } from '@datamoc/mwg/two-d/render';
// registered automatically by TintedSprite.ts; naming the class directly is only ever
// needed to check which batcher an object ended up in, or in Pixi extension diagnostics
console.log(ColorTransformBatcher.extension.name); // 'mwg-color-transform'
DeformableSprite
class
A sprite that deforms by pinned control points on the existing GPU mesh path.
import { DeformableSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const wingTexture: Texture2D;
const wing = new DeformableSprite(wingTexture, { columns: 4, rows: 4 });
wing.dragVertex(wing.indexOf(3, 0), 99, -6); // the wingtip bends on the GPU mesh
console.log(wing.vertexAt(wing.indexOf(0, 1)).x); // 0 - the pinned root stays put
console.log(wing.rotation); // 0 - dragging never turns the whole sprite
EMPTY
const
The frame value meaning "nothing here"; a cell holding it gets no sprite at all.
import { EMPTY, TileMap, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
const map = new TileMap({ width: 10, height: 10, sheet });
map.addLayer('ground');
console.log(map.getTile('ground', 0, 0)); // EMPTY - nothing set there yet
Flights
class
Owns the projectile flights a game has in the air: one add per shot, one update(dt) driving them all, onArrive dispatched exactly once per arrival, and no bookkeeping left to the caller.
import { Flights } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const layer: Container2D;
declare const bolt: { x: number; y: number };
const flights = new Flights();
layer.addChild(flights);
const flight = flights.add(bolt, { x: 0, y: 0 }, { x: 200, y: 0 }, {
...
FogLayer
class
A smooth fog-of-war overlay: a small canvas with resolution pixels per tile, scaled up with linear filtering, so fog edges blur into the next cell instead of stepping by whole tiles. Draw it above terrain and characters, and call ...
import { FogLayer } from '@datamoc/mwg/two-d/render';
const fog = new FogLayer({
width: 20,
height: 20,
palette: [[0, 0, 0, 0], [0, 0, 0, 150], [0, 0, 0, 255]], // seen, remembered, unknown
});
fog.refresh((x, y) => (x < 5 ? 0 : 2));
Gradient
const
A gradient fill/stroke, passed to Shape2D.fill/.stroke the same way a plain colour is.
import { Shape2D, Gradient } from '@datamoc/mwg/two-d/render';
const sky = new Gradient({
type: 'linear',
start: { x: 0, y: 0 },
end: { x: 0, y: 1 },
colorStops: [
{ offset: 0, color: 0x1a1a3a },
{ offset: 1, color: 0x3a2a4a },
],
...
Halo
class
The glow around a unit, an aura, a shrine's light: an animated sprite that follows a target and is drawn additively.
import { Halo, HALO_ANIMATION, SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const sheet: SpriteSheet;
declare const unit: { x: number; y: number };
const glow = new Halo({ frames: sheet.range(20, 22), animation: { fps: 6 }, offsetY: -6 });
glow.follow(unit.x, unit.y);
glow.update(1 / 60);
console.log(glow.playing === HALO_ANIMATION); // true - a halo is always playing its own cycle
HasColorAdd
interface
Carries the additive colour for one renderable.
import type { HasColorAdd } from '@datamoc/mwg/two-d/render';
import { packColorAdd } from '@datamoc/mwg/two-d/render';
function tintTowardsWhite(renderable: HasColorAdd, strength: number): void {
renderable.colorAdd = packColorAdd(strength, strength, strength);
}
HazeLayer
class
An atmospheric haze sheet: translucent weather and mood with soft edges, drift and density, on the GPU path (roadmap 425). Distinct from FogLayer, which is fog of war: this never hides gameplay, it only colours the air. The canvas stays ...
import { HazeLayer } from '@datamoc/mwg/two-d/render';
const far = new HazeLayer({ width: 400, height: 225, density: 0.3, tint: 0x9db8d6, drift: { x: 0.1, y: 0 } });
const near = new HazeLayer({ width: 400, height: 225, density: 0.5, tint: 0xcfd8e6, drift: { x: 0.4, y: 0.05 } });
far.update(1 / 60); // each plane drifts at its own pace, which reads as depth
near.update(1 / 60);
LayeredSprite
class
A character assembled from independently swappable, independently tintable layers - skin, eyes, hair, garments, worn equipment - rather than one flat sprite per combination. A second palette is a colour change on one layer; worn equipment ...
import { LayeredSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const skinTexture: Texture2D;
declare const hairTexture: Texture2D;
declare const swordTexture: Texture2D;
const hero = new LayeredSprite();
hero.addLayer('skin', skinTexture, 0);
hero.addLayer('hair', hairTexture, 1);
...
LightningArc
class
The position data for a jagged line between two points - a lightning bolt, a tether, a chain lightning link - the temporised two-point visual two-d/render had no helper for. Owns geometry only, the same division Projectile draws for a ...
import { LightningArc } from '@datamoc/mwg/two-d/render';
const bolt = new LightningArc({ x: 0, y: 0 }, { x: 100, y: 0 }, { duration: 0.2, segments: 4 });
console.log(bolt.points.length); // 6 - both endpoints plus 4 interior points
const expired = bolt.update(0.25);
console.log(expired); // true - the instant it outlives its duration
LiquidLayer
class
An animated liquid surface under a tile map: one scrolling tiling quad per liquid cell, so each cell can carry its own visibility tint (setCellColor, the same idea as TileMap.setCellColor) without showing liquid in cells the player has ...
import { Texture } from 'pixi.js';
import { LiquidLayer } from '@datamoc/mwg/two-d/render';
const water = new LiquidLayer({
texture: Texture.EMPTY,
width: 8,
height: 8,
isLiquid: (x, y) => y > 5,
});
water.setCellColor(3, 6, 0xffffff); // lit; a tint of 0 hides the cell
...
Minimap
class
A downscaled picture of a level, built from exactly the data roguelike.FieldOfView already tracks - explored, distinct from visible - plus a per-cell colour a game supplies, since mwg has no opinion on what a wall or a floor should ...
import { Minimap } from '@datamoc/mwg/two-d/render';
const minimap = new Minimap({ widthInCells: 40, heightInCells: 24, cellSize: 2 });
const explored = new Set([0, 1, 40]);
minimap.sync(explored, (x, y) => ((x + y) % 2 === 0 ? 0x3a3a2a : 0x2f5a2f));
minimap.setMarker(0, 0, Math.PI / 2); // the hero, facing down
console.log(minimap.exploredCount); // 3
NO_COLOR_ADD
const
the packed value of no additive colour at all - what an untinted sprite carries.
import { NO_COLOR_ADD, TintedSprite } from '@datamoc/mwg/two-d/render';
declare const rat: TintedSprite;
console.log(rat.colorAdd === NO_COLOR_ADD); // true - fresh sprites start with no additive colour
NeonGlow
class
A soft neon glow from a shape or mask: a radial sprite drawn additively, the overlay Halo already provides, with colour, radius, intensity and flicker (roadmap 426). A global bloom stays out on purpose: one sprite per sign is cheaper, ...
import { NeonGlow } from '@datamoc/mwg/two-d/render';
const sign = new NeonGlow({ color: 0x37e0ff, radius: 48, flicker: { amount: 0.15, frequency: 2 } });
sign.position.set(200, 120);
sign.update(1 / 60); // the tube breathes; under reduced motion it holds steady instead
Node2D
class
A plain grouping container under an MWG-owned name, for the common "just group some children" need Scene2D.stage itself covers for the whole scene - a sub-layer inside it (a HUD strip, a creature layer, a popup stack) still needs its own ...
import { Node2D, Shape2D } from '@datamoc/mwg/two-d/render';
const hud = new Node2D();
hud.addChild(new Shape2D().rect(0, 0, 100, 20).fill(0x202020));
hud.x = 12;
hud.y = 12;
ParticleEmitter
class
A pooled particle emitter: sparks off a sword hit, dust under a footstep, rain over a map.
import { ParticleEmitter, type Texture2D } from '@datamoc/mwg/two-d/render';
declare const sparkTexture: Texture2D;
const sparks = new ParticleEmitter({
texture: sparkTexture,
max: 64,
life: [0.2, 0.4],
speed: [80, 160],
gravity: { x: 0, y: 300 },
...
PinnedDeform
class
A pinned-point deformation grid: the renderer-free math behind roadmap item 414.
import { PinnedDeform } from '@datamoc/mwg/two-d/render';
const wing = new PinnedDeform({ columns: 4, rows: 3, width: 96, height: 64 });
wing.drag(wing.indexOf(3, 0), 99, -6); // the wingtip follows the bone
console.log(wing.at(wing.indexOf(0, 1))); // { x: 0, y: 32 } - the pinned root never moves
console.log(wing.movedCount()); // 1 - only the dragged tip left its rest position
Projectile
class
Tweens a sprite's position in a straight line, world units to world units.
import { Projectile } from '@datamoc/mwg/two-d/render';
declare const boltSprite: { x: number; y: number };
const bolt = new Projectile(boltSprite, { x: 0, y: 0 }, { x: 200, y: 0 }, { speed: 400 });
const arrived = bolt.update(1 / 60);
console.log(bolt.progress); // small, just past 0 - one frame into the flight
console.log(arrived); // false - not there yet
console.log(boltSprite.x); // moved partway from 0 towards 200
RENDERING_DECISIONS
const
The framework's rendering choices by workload. This is data, not a global renderer switch: each game imports only the module it uses and keeps 2D Pixi and optional 3D Babylon paths independent.
import { RENDERING_DECISIONS } from '@datamoc/mwg/two-d/render';
const particles = RENDERING_DECISIONS.find((d) => d.workload === 'particles');
console.log(particles?.preferred); // 'PixiJS for 2D, Babylon.js for 3D'
RPGM_FLOOR_AUTOTILE_TABLE
const
The standard MV floor-autotile shape table (48 shapes), for slots 0-1: which four atlas quadrants each baked shape assembles, in 24px source cells. Entries are [x, y] offsets, row-major [topLeft, topRight, bottomLeft, bottomRight], ...
import { RPGM_FLOOR_AUTOTILE_TABLE, RPGM_WALL_AUTOTILE_TABLE } from '@datamoc/mwg/two-d/render';
console.log(RPGM_FLOOR_AUTOTILE_TABLE.length); // 48 - one entry per baked shape
console.log(RPGM_WALL_AUTOTILE_TABLE.length); // 16 - walls cycle every 16 shapes
console.log(RPGM_FLOOR_AUTOTILE_TABLE[47]); // [[0,0],[1,0],[0,1],[1,1]] - the full tile
RpgmAutotileAtlas
class
Caches RPG Maker autotile frame descriptions for one fixed shape table.
import { RpgmAutotileAtlas, type RpgmAutotileShapeTable } from '@datamoc/mwg/two-d/render';
declare const shapeTable: RpgmAutotileShapeTable;
const atlas = new RpgmAutotileAtlas(shapeTable);
const frame = atlas.get(7, 0, 3);
console.log(frame.quadrants.length); // 4
ScreenEffects
class
A full-screen colour wash over everything else: fade out to black between floors, flash white on a critical hit, hold a red tint while poisoned.
import { ScreenEffects } from '@datamoc/mwg/two-d/render';
const effects = new ScreenEffects({ width: 640, height: 360 });
// add effects last: game.stage.addChild(effects);
effects.flash(0.2, 0xffffff); // a critical hit
const done = effects.update(1 / 60);
console.log(done); // false - still mid-flash
console.log(effects.isBusy); // true
...
Shape2D
class
Vector drawing - rectangles, circles, polygons, lines - under an MWG-owned name.
import { Shape2D } from '@datamoc/mwg/two-d/render';
const marker = new Shape2D().circle(0, 0, 6).fill(0xff4040).stroke({ color: 0x000000, width: 1 });
marker.x = 100;
marker.y = 60;
SkeletalSprite
class
Bones driving drawn parts (roadmap 400), the drawing half of the skeleton module.
import * as Skeleton from '@datamoc/mwg/skeleton';
import { Texture, TextureSource } from 'pixi.js';
import { SkeletalSprite } from '@datamoc/mwg/two-d/render';
const puppet = Skeleton.create([{ name: 'arm', length: 12 }]);
const mixer = Skeleton.createMixer(puppet, {
id: 'idle', duration: 1, loop: true,
frames: [{ time: 0, rotations: { arm: 0 } }, { time: 1, rotations: { arm: 90 } }],
});
const rig = new SkeletalSprite(puppet, mixer);
...
SpringSprite
class
A debug line from a spring's anchor to its bob: one straight segment that stretches, overshoots and settles with the simulation. A game draws hair or hems by placing its own sprites with Spring.place, and keeps this around while tuning ...
import { Spring } from '@datamoc/mwg/physics';
import { SpringSprite } from '@datamoc/mwg/two-d/render';
const spring = new Spring({ stiffness: 120, damping: 8 });
const line = new SpringSprite({ spring });
spring.setAnchor(10, 0);
spring.update(1 / 60);
line.sync(); // the segment now leans toward the anchor
Sprite2D
class
A plain, untinted sprite under an MWG-owned name - the neutral case TintedSprite is not: that class exists to add the per-sprite multiply-and-add colour transform, so it is the wrong reach for a sprite that needs no colour effect at all. ...
import { Sprite2D } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const heroTexture: Texture2D;
const hero = new Sprite2D(heroTexture);
hero.x = 40;
hero.y = 60;
SpriteAttachment
class
Ties a second sprite's position to a first one's, with its own offset and its own optional lifetime - a shadow flat beneath a unit, a status icon floating above it, a shield glyph at a corner. StatusVisuals only tints a sprite that ...
import { SpriteAttachment } from '@datamoc/mwg/two-d/render';
declare const shadowSprite: { x: number; y: number };
declare const statusIcon: { x: number; y: number };
const shadow = new SpriteAttachment(shadowSprite, { offsetY: 4 });
const poisoned = new SpriteAttachment(statusIcon, { offsetY: -20, duration: 3 });
shadow.follow(100, 200);
poisoned.follow(100, 200);
...
SpriteGroup
class
A crowd that only animates its on-screen members.
import { SpriteGroup, AnimatedSprite, Camera } from '@datamoc/mwg/two-d/render';
declare const camera: Camera;
const crowd = new SpriteGroup();
const goblin = new AnimatedSprite();
crowd.add(goblin);
function onFrame(dt: number): void {
crowd.update(camera, dt); // goblins off screen keep their frame, for free
...
SpriteMotion
class
Squash, hop, bob, wobble, spin, shear and flip over a display object's transform, each running through a Tweener so reduced motion is honoured once, here, instead of by every game's own scale/Math.sin write.
import { SpriteMotion } from '@datamoc/mwg/two-d/render';
const rat = { x: 0, y: 0, rotation: 0, scale: { x: 1, y: 1 }, skew: { x: 0, y: 0 } };
const motion = new SpriteMotion(rat);
motion.hop({ height: 6, duration: 0.3 }); // up and back down
motion.squash({ amount: 0.2, duration: 0.3 }); // composes with the hop
motion.flip('x'); // face the other way, composes with both
motion.update(1 / 60); // in the game loop
...
StatusVisuals
class
Turns a set of active status-effect names into one additive colour on a sprite, so a game's own actors.applyStatusEffect handles - which know nothing about rendering - can drive what a character looks like without either side importing ...
import { StatusVisuals, TintedSprite, type Texture2D } from '@datamoc/mwg/two-d/render';
declare const ratTexture: Texture2D;
const rat = new TintedSprite(ratTexture);
rat.tint = 0x8080ff; // a team colour, untouched by anything below
const visuals = new StatusVisuals(rat, {
styles: {
poisoned: { color: 0x00ff00, strength: 0.5 },
burning: { color: 0xff6600, strength: 0.6, pulseRate: 2 },
...
TerrainGraphicsLayer
class
Draws the placements resolveTerrainGraphics resolves - the renderer half of [terrain_graphics] that the rule pass deliberately leaves to a caller.
import { TerrainGraphicsLayer, resolveTerrainGraphics } from '@datamoc/mwg/two-d/render';
const placements = resolveTerrainGraphics(2, 1, [], () => new Set(['land']));
const layer = new TerrainGraphicsLayer({
placements,
project: (x, y, dx, dy) => ({ x: (x + dx) * 32, y: (y + dy) * 32 }),
});
layer.setPlacements(placements);
Text2D
class
Text under an MWG-owned name, for the case ui.Label/ui.BitmapLabel do not cover - a one-off caption inside a Shape2D-drawn diagram, say, rather than themed game-wide UI text. Prefer Label/BitmapLabel for anything styled through ...
import { Text2D } from '@datamoc/mwg/two-d/render';
const caption = new Text2D({ text: 'N', style: { fill: 0xffffff, fontSize: 14 } });
caption.x = 40;
caption.y = 4;
TileMap
class
A grid of tiles, drawn as sprites.
import { TileMap, EMPTY, SpriteSheet, createCamera } from '@datamoc/mwg/two-d/render';
const sheet = SpriteSheet.grid('tiles/ground.png', 16);
const map = new TileMap({ width: 20, height: 20, sheet });
map.addLayer('ground', new Array(400).fill(0)); // frame 0 everywhere
map.addLayer('objects'); // an empty layer, filled in with setTile later
map.setTile('objects', 3, 3, 5);
console.log(map.getTile('objects', 3, 3)); // 5
...
TiledSprite
class
A texture that repeats/scrolls to fill a shape, rather than stretching - a parallax background, a scrolling water or lava tile. TilingSprite's own tilePosition/tileScale already covers the "scrolling tile layer" need in full; this is ...
import { TiledSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const waterTexture: Texture2D;
const water = new TiledSprite({ texture: waterTexture, width: 320, height: 96 });
water.tilePosition.x -= 12; // scroll it a little each frame, from update(dt)
TintedSprite
class
A sprite that can be pulled *towards* a colour, not just darkened.
import { TintedSprite } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
const rat = new TintedSprite({ texture });
rat.lerpTint(0x00ff00, 0.5); // half-way to green: poisoned
rat.lerpTint(0xffffff, 0.8); // nearly white: just took a hit
rat.tint = 0x404060; // plain multiply: standing in the dark
rat.silhouette(0x000000); // a solid black shape, keeping only the sprite's outline
...
TransitionRunner
class
Plays one transition: owns the under and over layers, advances with update(dt) in real time and tells its onDone once. Game runs one of these around a scene change; it is exported so a game with its own compositing can run a ...
import { Container2D as Container } from '@datamoc/mwg/two-d/render';
import { TransitionRunner, dissolve } from '@datamoc/mwg/two-d/render';
const incoming = new Container();
const runner = new TransitionRunner({ transition: dissolve(), duration: 0.5 }, { width: 800, height: 600 }, null, incoming);
// put runner.under, incoming and runner.over on the stage, in that order
while (!runner.update(1 / 60)) continue;
console.log(runner.done); // true
Viewport
class
One player's own rectangle of the screen, for a same-screen local multiplayer layout: a Camera whose world is offset to start at x/y instead of the corner of the canvas, and clipped to width/height so it never bleeds into a ...
import { Viewport, splitScreenHalves } from '@datamoc/mwg/two-d/render';
const [left, right] = splitScreenHalves(640, 360);
const player1 = new Viewport({ ...left, zoom: 2 });
const player2 = new Viewport({ ...right, zoom: 2 });
// add both containers: game.stage.addChild(player1.container, player2.container);
player1.camera.follow({ x: 50, y: 50 });
player1.update(1 / 60);
XP_AUTOTILE_PATTERNS
const
The standard XP autotile pattern table (48 patterns). An XP autotile image is a 96 by 128 template: a 6-column grid of 16px mini-blocks, and each baked pattern assembles four of them. tile % 48 selects the pattern; tile / 48 (floored) ...
import { XP_AUTOTILE_PATTERNS, XP_NEIGHBORS_TO_PATTERN } from '@datamoc/mwg/two-d/render';
console.log(XP_AUTOTILE_PATTERNS.length); // 48 - one entry per baked pattern
console.log(XP_AUTOTILE_PATTERNS[0]); // [26,27,32,33] - the filled interior tile
console.log(XP_NEIGHBORS_TO_PATTERN.length); // 256 - one entry per 8-neighbour mask
console.log(XP_NEIGHBORS_TO_PATTERN[255]); // 0 - surrounded on all sides is the interior
applyAllImageModifiers
function
The two-step recipe applyImageModifiers's own doc comment names but leaves a caller to drive by hand (item 310): bakes applyTextureModifiers's exact pixel-level result (~RC/~PAL/~BLIT/~MASK/~BLEND/~ROTATE) into ...
import { Sprite, Texture } from 'pixi.js';
import { applyAllImageModifiers, parseImagePath } from '@datamoc/mwg/two-d/render';
const sprite = new Sprite(Texture.EMPTY);
applyAllImageModifiers(sprite, parseImagePath('hero.png~BLEND(ff0000,50%)~FL(horizontal)'));
// sprite.texture carries the exact per-pixel blend; sprite.scale.x is flipped
applyImageModifiers
function
Apply the renderer-neutral subset supported by the Pixi backend.
import { Sprite } from 'pixi.js';
import { applyImageModifiers, parseImagePath } from '@datamoc/mwg/two-d/render';
applyImageModifiers(new Sprite(), parseImagePath('hero.png~FL(horizontal)'));
applyTextureModifiers
function
Applies the modifiers that need real pixel access or a sibling texture rather than a sprite property or a Pixi filter: ~RC (exact palette swap, src>dst hex or named-colour pairs - see resolveColor/parseColorPairs), ~PAL (the same ...
import { Texture } from 'pixi.js';
import { applyTextureModifiers, parseImagePath } from '@datamoc/mwg/two-d/render';
const recolored = applyTextureModifiers(Texture.EMPTY, parseImagePath('unit.png~RC(ff00ff>ff0000)'));
assertAutotileLayout
function
Checks a resolved autotile layout once, up front. Throws naming the first problem: a bad slot, an empty shape table, an empty animation cycle, a cycle kind outside its slot's kinds, a bad XP index, or a frame count below one.
import {
assertAutotileLayout,
RPGM_FLOOR_AUTOTILE_TABLE,
} from '@datamoc/mwg/two-d/render';
assertAutotileLayout({ format: 'rpgm-mv', slot: 0, table: RPGM_FLOOR_AUTOTILE_TABLE, cycles: [[0, 1, 2]] });
assertAutotileLayout({ format: 'rpgm-xp', index: 2, frames: 1, single: false });
autotileCellParts
function
Resolves one autotile cell to its drawn pieces: four quadrant halves for a template cell, one whole-tile piece for an XP single-tile strip, or null for a blank cell (tile <= 0 reads as blank; EMPTY is -1).
import {
autotileCellParts,
RPGM_FLOOR_AUTOTILE_TABLE,
} from '@datamoc/mwg/two-d/render';
const parts = autotileCellParts(
{ format: 'rpgm-mv', slot: 0, table: RPGM_FLOOR_AUTOTILE_TABLE, cycles: [] },
2048, 0,
);
console.log(parts?.length); // 4 - one piece per quadrant
...
autotileFrames
function
One frame per cell of a terrain, autotiled from a same-terrain test alone.
import { autotileFrames, BLOB_SHAPES } from '@datamoc/mwg/two-d/render';
const water = new Set([0, 1, 10, 11]); // a 2x2 pond in a wider grid
const frames = Array.from({ length: BLOB_SHAPES.length }, (_, index) => index); // frame N = shape N
const cellFrames = autotileFrames(10, 10, (x, y) => water.has(y * 10 + x), frames);
console.log(cellFrames[0]); // the blob frame for the pond's top-left corner
blendMatrix
function
A live, per-frame colour-matrix approximation of a blend towards color (0xRRGGBB) by ratio, the same shape TintedSprite.lerpTint gives an additive-capable sprite, expressed here as a plain matrix so a bare Pixi Sprite can use it ...
import { blendMatrix } from '@datamoc/mwg/two-d/render';
const matrix = blendMatrix(0xff0000, 0.5); // half-way to red
blendPixels
function
The renderer-free core of ~BLEND: every pixel's RGB is lerped ratio of the way towards color (0xRRGGBB), an exact per-pixel blend rather than blendMatrix's runtime approximation - this is what applyTextureModifiers bakes into the ...
import { blendPixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray([0, 0, 0, 255]); // one opaque black pixel
const out = blendPixels(pixels, 0xff0000, 0.5);
console.log([...out]); // [128, 0, 0, 255] - half-way to red
blobIndex
function
the blob index (0 to 46) for one cell's 8-neighbour membership test
import { blobIndex } from '@datamoc/mwg/two-d/render';
const surroundedOnAllSides = blobIndex({
n: true, e: true, s: true, w: true,
ne: true, se: true, sw: true, nw: true,
});
console.log(surroundedOnAllSides); // 46 - the fully-interior shape
channelScaleMatrix
function
Build the matrix for ~R/~G/~B(percent): scales one or more channels by a percentage (100 = unchanged), leaving any channel not passed at 100%.
import { channelScaleMatrix } from '@datamoc/mwg/two-d/render';
const matrix = channelScaleMatrix({ red: 150 }); // ~R(150), a 50% brighter red channel
console.log(matrix[0]); // 1.5
channelSwapMatrix
function
Build the matrix for ~CHAN(r,g,b,a): each output channel is fed by whichever named input channel (or constant black/white) is given for it, missing trailing entries left as the output channel's own identity source (R,G,B,A).
import { channelSwapMatrix } from '@datamoc/mwg/two-d/render';
const matrix = channelSwapMatrix(['B', 'G', 'R']); // swap the red and blue channels
colorShiftMatrix
function
Build the Pixi 5x4 colour-matrix for a Wesnoth CS(r,g,b) shift.
import { colorShiftMatrix } from '@datamoc/mwg/two-d/render';
const matrix = colorShiftMatrix(-20, 0, 30);
console.log(matrix.length); // 20
createCamera
function
a camera sized to the running game's viewport, updated on every frame
import { createCamera } from '@datamoc/mwg/two-d/render';
const camera = createCamera({ zoom: 2 }); // sized to Game.current automatically
createColorBlindnessFilter
function
A whole-scene filter simulating one of the three common types of colour vision deficiency. Assign the result to a container's .filters (typically a game's whole stage), the same way any Pixi filter is applied - nothing here is ...
import { createColorBlindnessFilter } from '@datamoc/mwg/two-d/render';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const stage: Container2D;
stage.filters = [createColorBlindnessFilter('deuteranopia')];
createLayers
function
Creates one labeled Node2D per name, attached to parent in the order given, and returns them keyed by name. A game with a dozen conventionally-ordered layers (terrain, units, effects, UI, ...) otherwise hand-wires each one and adds ...
import { Node2D, createLayers, Sprite2D } from '@datamoc/mwg/two-d/render';
const stage = new Node2D();
const layers = createLayers(stage, ['terrain', 'units', 'effects', 'ui']);
layers.units.addChild(new Sprite2D());
console.log(layers.effects.label); // 'effects'
croppedTexture
function
Make a cropped view without mutating the shared source texture.
import { Texture } from 'pixi.js';
import { croppedTexture, parseImagePath } from '@datamoc/mwg/two-d/render';
const cropped = croppedTexture(Texture.EMPTY, parseImagePath('atlas.png~CROP(0,0,16,16)'));
detectWebGpu
function
The real, asynchronous WebGPU/WGSL check inspectGraphicsCapabilities cannot do synchronously: requests an actual adapter and device, then compiles an actual shader module and reads back its compilation diagnostics. Feed the result to ...
import { detectWebGpu, inspectGraphicsCapabilities } from '@datamoc/mwg/two-d/render';
const detected = await detectWebGpu();
const capabilities = inspectGraphicsCapabilities({ webgpu: detected.webgpu, wgsl: detected.wgsl });
console.log(capabilities.wgsl); // true only if a real WGSL shader actually compiled
dissolve
function
The old picture fading out over the new scene.
import { dissolve } from '@datamoc/mwg/two-d/render';
console.log(dissolve().name); // 'dissolve'
glowFalloff
function
How much light a soft glow still carries at distance from its centre: 1 at the centre, easing to exactly 0 at radius, 0 past it. Pure, so a sign's falloff is checkable as arithmetic: the middle of a tube is always brighter than its rim.
import { glowFalloff } from '@datamoc/mwg/two-d/render';
console.log(glowFalloff(0, 24)); // 1 - the centre burns full
console.log(glowFalloff(24, 24)); // 0 - the rim carries nothing
console.log(glowFalloff(48, 24)); // 0 - past the rim stays dark
grow
function
The new scene growing from a point (the centre unless from names one) without overshoot.
import { grow } from '@datamoc/mwg/two-d/render';
console.log(grow().name); // 'grow'
hazeField
function
A deterministic wisp field in 0..1: two octaves of value noise over an integer lattice, smoothstep-interpolated so neighbouring samples always blend rather than step. Pure and seed-stable within a run: the same (x, y, time, seed) paints ...
import { hazeField } from '@datamoc/mwg/two-d/render';
console.log(hazeField(3, 5, 1.5, 7) === hazeField(3, 5, 1.5, 7)); // true - same sky twice
console.log(hazeField(3, 5, 1.5, 7) >= 0 && hazeField(3, 5, 1.5, 7) <= 1); // true - always a density
hexRotate
function
TerrainRotate for an axial hex grid: exact 60-degree steps (rotations must be a multiple of 6, or 0), via the standard cube-coordinate rotation (q,r,s -> -r,-s,-q per 60-degree step, s = -q - r).
import { hexRotate } from '@datamoc/mwg/two-d/render';
console.log(hexRotate(1, 0, 1, 6)); // one 60-degree step of the axial offset (1, 0)
imageModifier
function
Return one named modifier from a parsed image path.
import { imageModifier, parseImagePath } from '@datamoc/mwg/two-d/render';
const flip = imageModifier(parseImagePath('hero.png~FL(horizontal)'), 'FL');
inspectGraphicsCapabilities
function
Reports browser graphics APIs without creating a renderer or selecting a fallback. Pass a probe in tests or host integrations; omitting it reads the current browser safely.
import { inspectGraphicsCapabilities } from '@datamoc/mwg/two-d/render';
// reads the real browser when no probe is given
const here = inspectGraphicsCapabilities();
// a fake probe, for a test or a host integration with no DOM
const capabilities = inspectGraphicsCapabilities({
createCanvas: () => ({ getContext: (kind) => (kind === 'webgl2' ? {} : null) }),
webgpu: false,
});
...
iris
function
A circle opening from a point (the centre unless from names one) that shows the new scene through it, the old picture around it.
import { iris } from '@datamoc/mwg/two-d/render';
console.log(iris({ from: { x: 120, y: 80 } }).name); // 'iris'
isOnScreen
function
Whether a world-space point is on screen, for skipping animation work nobody sees.
import { Camera, isOnScreen } from '@datamoc/mwg/two-d/render';
declare const camera: Camera;
if (isOnScreen(camera, 160, 160)) console.log('the market square, animate it');
console.log(isOnScreen(camera, 10000, 10000, 32)); // far away, even with a margin
loadSvgSkeleton
function
Load authored SVG limbs, normalize each around its pivot, and bind them to a skeleton. Uses SVG data-pivot-x / data-pivot-y attributes or pivots supplied by the game. Art is rotated back from its rest angle before rasterization, so rest ...
import { create } from '@datamoc/mwg/skeleton';
import { loadSvgSkeleton } from '@datamoc/mwg/two-d/render';
import { releaseSvgParts } from '@datamoc/mwg/assets';
const loaded = await loadSvgSkeleton('<svg xmlns="http://www.w3.org/2000/svg" width="30" height="10"><g id="arm" data-pivot-x="0" data-pivot-y="5"><rect width="30" height="10"/></g></svg>', {
skeleton: create([{ name: 'arm', length: 30, origin: { x: 0, y: 5 } }]), resolution: 2,
});
loaded.sprite.apply({ arm: 45 });
loaded.sprite.destroy({ children: true });
await releaseSvgParts(loaded.textures);
loadTiledMap
function
Builds a TileMap from parsed Tiled JSON and sprite sheets already cut to tile size.
import { loadTiledMap, SpriteSheet, type TiledMapData, type TiledTilesetData, type TilesetSheet } from '@datamoc/mwg/two-d/render';
import * as Resources from '@datamoc/mwg/assets';
const map: TiledMapData = Resources.get('maps/village.json');
const sheets: TilesetSheet[] = map.tilesets.map((ref) => {
if (ref.source === undefined) {
return { firstgid: ref.firstgid, sheet: SpriteSheet.grid('tiles/ground.png', map.tilewidth, map.tileheight) };
}
const tileset: TiledTilesetData = Resources.get(ref.source);
return { firstgid: ref.firstgid, sheet: SpriteSheet.grid(tileset.image, tileset.tilewidth, tileset.tileheight) };
...
maskPixels
function
The renderer-free core of ~MASK: base's alpha channel is multiplied by mask's alpha at the given offset (a pixel the mask does not cover keeps zero alpha), base's own colour left untouched. Exported so the masking logic is testable ...
import { maskPixels } from '@datamoc/mwg/two-d/render';
const base = new Uint8ClampedArray([10, 20, 30, 255]); // one opaque pixel
const mask = new Uint8ClampedArray([0, 0, 0, 128]); // half-alpha mask, same 1x1 size
const out = maskPixels(base, 1, 1, mask, 1, 1, 0, 0);
console.log([...out]); // [10, 20, 30, 128]
matchTerrainRule
function
Whether rule, rotated by rotationIndex of rotations steps, matches the cell at x, y.
import { matchTerrainRule } from '@datamoc/mwg/two-d/render';
const rule = { id: 'coast', conditions: [{ dx: 1, dy: 0, hasAll: ['water'] }], images: [] };
const flagsAt = (x: number, y: number) => (x === 1 && y === 0 ? new Set(['water']) : new Set(['land']));
console.log(matchTerrainRule(rule, 0, 0, flagsAt)); // true
minimapCellCenter
function
The minimap centre for a cell, useful for overlays that must share its topology.
import { minimapCellCenter } from '@datamoc/mwg/two-d/render';
const centre = minimapCellCenter(3, 2, 4, 'hex');
newlyRevealed
function
the cell indices in explored not yet baked into a minimap's texture - pure, so it can be tested without a renderer
import { newlyRevealed } from '@datamoc/mwg/two-d/render';
const explored = new Set([4, 9, 12]);
const alreadyDrawn = new Set([4]);
console.log(newlyRevealed(explored, alreadyDrawn)); // [9, 12]
openAnimationFrame
function
What a window opening (or closing, run backwards) looks like at progress: its scale, opacity and how far it is still pushed down by a slide. Closing never overshoots.
import { openAnimationFrame } from '@datamoc/mwg/two-d/render';
console.log(openAnimationFrame('pop', 0).scale); // 0
console.log(openAnimationFrame('pop', 1).scale); // 1
console.log(openAnimationFrame('slide', 0.5).offset > 0); // true: still sliding up
packColorAdd
function
Packs an additive colour into the form the attribute expects.
import { packColorAdd } from '@datamoc/mwg/two-d/render';
import { TintedSprite } from '@datamoc/mwg/two-d/render';
declare const rat: TintedSprite;
rat.colorAdd = packColorAdd(0, 1, 0); // pull the sprite towards pure green
packTintAdd
function
the additive half of lerp(texel, colour, strength); pair with a tint of 1 - strength
import { packTintAdd, TintedSprite } from '@datamoc/mwg/two-d/render';
declare const rat: TintedSprite;
rat.colorAdd = packTintAdd(0x00ff00, 0.5); // half-way to green, the additive half of lerpTint
rat.tint = 0x808080; // pair with 1 - strength (here 0.5) for the multiply half
paintFogPixels
function
Paints per-cell fog into an RGBA pixel buffer of width * resolution by height * resolution pixels. Pure: no canvas involved, so the shading rule is checkable anywhere. A state with no palette entry paints transparent.
import { paintFogPixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray(2 * 1 * 4);
paintFogPixels(pixels, 2, 1, 1, (x) => x, [[0, 0, 0, 0], [0, 0, 0, 255]]);
console.log(pixels[7]); // 255 - the second cell is fully fogged
paintGlowPixels
function
Paint a size * size radial glow (the sprite is square, size is odd) into pixels as RGBA bytes. Centre pixels carry the colour at full intensity, rim pixels fade to transparent, and intensity scales every channel linearly, so doubling ...
import { paintGlowPixels } from '@datamoc/mwg/two-d/render';
const size = 5;
const pixels = new Uint8ClampedArray(size * size * 4);
paintGlowPixels(pixels, size, { color: 0xff0000, radius: 2, intensity: 1 });
console.log(pixels[0] > pixels[pixels.length - 4]); // true - the centre burns redder than the rim
paintHazePixels
function
Paint a white haze sheet with per-pixel alpha into pixels (width * height RGBA). The layer tints the sprite itself, so one buffer serves any colour. Pure, like paintFogPixels: density 0 paints fully transparent, and raising the ...
import { paintHazePixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray(4 * 2 * 4);
paintHazePixels(pixels, 4, 2, { time: 0, density: 1, seed: 3 });
console.log(pixels.some((v, i) => i % 4 === 3 && v > 0)); // true - full density always shows some haze
paletteRangeMapping
function
Builds a PaletteMapping that recolours every entry of reference into a shade of the min -> mid -> max gradient, the shape a team colour or any other [color_range]-style remap is defined by. reference is the exact list of colours ...
import { paletteRangeMapping, recolorTexture } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
declare const magentaReference: readonly number[]; // the exact colours the art was painted with
const redTeam = paletteRangeMapping(magentaReference, { min: 0x000000, mid: 0xff0000, max: 0xffffff });
const recolored = recolorTexture(texture, redTeam); // 'exact': only those pixels are repainted
parseColorPairs
function
~RC(src>dst,src>dst,...)'s pairs, each side hex (#c0ffee, c0ffee) or, when resolve is given, a named colour (~RC(magenta>red)) it resolves. A pair whose either side is neither hex nor resolvable is dropped rather than aborting ...
import { parseColorPairs, parseImagePath, imageModifier } from '@datamoc/mwg/two-d/render';
const rc = imageModifier(parseImagePath('unit.png~RC(magenta>ff0000)'), 'RC')!;
const mapping = parseColorPairs(rc.args, (name) => (name === 'magenta' ? 0xff00ff : undefined));
console.log(mapping); // { from: [0xff00ff], to: [0xff0000] }
parseImagePath
function
Split top-level modifiers while keeping nested ~ paths inside arguments.
import { parseImagePath } from '@datamoc/mwg/two-d/render';
const parsed = parseImagePath('hero.png~FL(horizontal)~GS');
parsePaletteLists
function
~PAL(a,b,c>x,y,z)'s two comma-separated colour lists, one per side of a single >. The modifier's own args have already been comma-split by parseImagePath before this runs (the same naive split every modifier's argument list goes ...
import { parsePaletteLists, parseImagePath, imageModifier } from '@datamoc/mwg/two-d/render';
const pal = imageModifier(parseImagePath('unit.png~PAL(ff0000,00ff00>0000ff,ffff00)'), 'PAL')!;
console.log(parsePaletteLists(pal.args)); // { from: [0xff0000, 0x00ff00], to: [0x0000ff, 0xffff00] }
parseRotateMode
function
The sampling ~ROTATE(degrees, mode) asks for, from its second argument: linear (or smooth, since either word is what an author reaches for) interpolates, anything else - including nothing at all - takes the fast nearest-neighbour ...
import { parseImagePath, imageModifier, parseRotateMode } from '@datamoc/mwg/two-d/render';
const rotate = imageModifier(parseImagePath('tile.png~ROTATE(60,linear)'), 'ROTATE');
parseRotateMode(rotate?.args[1]); // 'linear'
parseRotateMode(undefined); // 'nearest'
pop
function
The new scene springing out of a point (the centre unless from names one), overshooting a little (overshoot, 1.08 when omitted) before it settles.
import { pop } from '@datamoc/mwg/two-d/render';
console.log(pop({ from: { x: 400, y: 300 } }).name); // 'pop'
popScale
function
The scale of something that pops in: 0 growing past 1 to overshoot, then settling on 1.
import { popScale } from '@datamoc/mwg/two-d/render';
console.log(popScale(0)); // 0
console.log(popScale(0.7)); // 1.08, the peak
console.log(popScale(1)); // 1
recolorTexture
function
Recolours a texture by palette remap: draws it to a canvas, runs remapPixels over the raw pixel data, and returns a new Texture wrapping the result. The source texture is never mutated, so the same base art recolours per team without ...
import { paletteRangeMapping, recolorTexture } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
const mapping = paletteRangeMapping([0xffffff, 0x808080, 0x000000], { min: 0xffe0e0, mid: 0xc00000, max: 0x400000 });
const redTeam = recolorTexture(texture, mapping, {}, 'nearest');
rectOf
function
Converts a pixi.js Rectangle to the plain Rect shape public APIs use. Internal to two-d; a game never constructs a pixi.js Rectangle to call this.
import { rectOf, type Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
const frame = rectOf(texture.frame);
console.log(frame); // { x: 0, y: 0, width: 32, height: 32 }, or whatever the texture was cut to
registerColorTransform
function
Registers the colour-transform batcher and the TintedSprite pipe with Pixi.
import { registerColorTransform } from '@datamoc/mwg/two-d/render';
// a game that assembles its own Pixi renderer extensions manually, instead of going
// through `Game`/`GameOptions.extensions`, registers the batcher itself before creating
// that renderer - importing `TintedSprite` (directly or transitively) already does this,
// so most games never call it
registerColorTransform();
remapPixels
function
The renderer-free core of a palette remap. In 'exact' mode (the default) a pixel is repainted only when it exactly matches one of mapping.from; every other pixel, including one close but not identical to a from colour, passes through ...
import { remapPixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray([255, 0, 255, 255, 10, 10, 10, 255]); // magenta, then dark grey
const out = remapPixels(pixels, { from: [0xff00ff], to: [0xff0000] }); // 'exact' by default
console.log([...out.slice(0, 4)]); // [255, 0, 0, 255] - the exact match repainted
console.log([...out.slice(4, 8)]); // [10, 10, 10, 255] - not a match, left alone
resolveEasing
function
the Easing function for a name or a function, defaulting to easeInOutQuad
import { resolveEasing } from '@datamoc/mwg/two-d/render';
console.log(resolveEasing('linear')(0.25)); // 0.25
console.log(resolveEasing((t) => t * t)(0.5)); // 0.25
resolveTerrainGraphics
function
Resolves a [terrain_graphics]-style rule set over a grid into the flat list of image placements a renderer draws, the generalisation Autotile's 47-shape blob table cannot express: a rule can test flags rather than only "same terrain", ...
import { resolveTerrainGraphics } from '@datamoc/mwg/two-d/render';
const water = new Set(['water']);
const land = new Set(['land']);
const flagsAt = (x: number, y: number) => (x === 0 ? water : land);
const placements = resolveTerrainGraphics(2, 1, [
{ id: 'coast', conditions: [{ dx: 0, dy: 0, hasAll: ['land'] }, { dx: -1, dy: 0, hasAll: ['water'] }], images: [{ image: 'coast.png' }] },
], flagsAt);
console.log(placements); // [{ x: 1, y: 0, ruleId: 'coast', image: 'coast.png', dx: 0, dy: 0, layer: 0 }]
rotatePixels
function
The renderer-free core of ~ROTATE: rotates the source pixels themselves by degrees (clockwise) around their centre and expands the surface to fit the rotated bounds, rather than turning a sprite's own transform - ...
import { rotatePixels } from '@datamoc/mwg/two-d/render';
const pixels = new Uint8ClampedArray([255, 0, 0, 255, 0, 255, 0, 255]); // 2x1: red, green
const rotated = rotatePixels(pixels, 2, 1, 90);
console.log(rotated.width, rotated.height); // 1, 2 - the surface expanded to fit
const smooth = rotatePixels(pixels, 2, 1, 60, 'linear'); // the same, with an interpolated edge
rpgmAutotileFrame
function
Describes how RPG Maker MV composes one autotile from four 24px atlas quadrants.
import { rpgmAutotileFrame } from '@datamoc/mwg/two-d/render';
const shapeTable = Array.from({ length: 48 }, () => [[0, 0], [1, 0], [0, 1], [1, 1]] as const);
const frame = rpgmAutotileFrame(7, 0, 3, shapeTable);
console.log(frame.quadrants[0].sourceX); // source pixel x coordinate
rpgmAutotileSlot
function
Which MV autotile family a raw map tile id belongs to, or null when the id is not an autotile at all (an EMPTY cell, a static B-E tile, an A5 tile).
import {
rpgmAutotileSlot,
RPGM_AUTOTILE_SLOT_BASES,
RPGM_AUTOTILE_SLOT_COUNTS,
} from '@datamoc/mwg/two-d/render';
console.log(rpgmAutotileSlot(2048)); // 0 - the first A1 tile
console.log(rpgmAutotileSlot(3000)); // 1 - inside the A2 range
console.log(rpgmAutotileSlot(42)); // null - a static tile, not an autotile
console.log(rpgmAutotileSlot(-1)); // null - EMPTY reads as blank, never as a slot
...
rpgmTableEdgeCells
function
The cells that get a table's front-edge strip, in the engine's own rule (Tilemap._paintTiles): a cell whose layer-1 neighbour above is an A2 table tile, while its own layer-1 tile is not a table, and whose layer-0 tile is not an A3/A4 ...
import { rpgmTableEdgeCells } from '@datamoc/mwg/two-d/render';
const table = 2816 + 48 * 0 + 47; // an A2 kind with the table flag
const flags = { [table]: 0x80 };
const cells = rpgmTableEdgeCells({ width: 1, height: 2, ground: [0, 0], objects: [table, 0], flags });
console.log(cells[1]); // the table tile above cell 1
console.log(cells[0]); // 0 - nothing above the top row
shrink
function
The old picture shrinking to a point (the screen's centre unless to names one) over the new scene.
import { shrink } from '@datamoc/mwg/two-d/render';
const closeToButton = shrink({ to: { x: 700, y: 40 } });
console.log(closeToButton.name); // 'shrink'
slide
function
Old and new pictures sliding together: the new one enters from the direction side while the old one leaves the other way.
import { slide } from '@datamoc/mwg/two-d/render';
console.log(slide({ direction: 'left' }).name); // 'slide'
snapZoom
function
The nearest zoom to zoom at which tileSize * zoom is a whole number of device pixels (resolution device pixels per screen pixel, default 1) - the pure math behind CameraOptions.pixelPerfectTileSize, exported so a game can snap a ...
import { snapZoom } from '@datamoc/mwg/two-d/render';
console.log(snapZoom(2.4, 16)); // 2.375 - 16 * 2.375 = 38, a whole number of pixels
splitScreenHalves
function
The two even halves of width×height a two-player split screen typically wants - side by side for a landscape screen, stacked for a portrait one - as plain rectangles a game hands straight to two Viewports (or to resize on a resize ...
import { splitScreenHalves } from '@datamoc/mwg/two-d/render';
const [top, bottom] = splitScreenHalves(400, 600); // taller than wide: stacks vertically
console.log(top); // { x: 0, y: 0, width: 400, height: 300 }
console.log(bottom); // { x: 0, y: 300, width: 400, height: 300 }
spriteColorMatrix
function
Attaches matrix to sprite as a ColorMatrixFilter, so a caller using colorShiftMatrix/channelScaleMatrix/blendMatrix/channelSwapMatrix never has to name pixi.js itself just to construct the one filter class those matrices ...
import { Sprite } from 'pixi.js';
import { blendMatrix, spriteColorMatrix } from '@datamoc/mwg/two-d/render';
const sprite = new Sprite();
spriteColorMatrix(sprite, blendMatrix(0xff0000, 0.5)); // half-blended red, no pixi.js import needed for the filter itself
squareRotate
function
TerrainRotate for a square or isometric grid: exact 90-degree steps only (rotations must be a multiple of 4, or 0).
import { squareRotate } from '@datamoc/mwg/two-d/render';
console.log(squareRotate(1, 0, 1, 4)); // { dx: 0, dy: 1 } - one 90-degree step
tileDelays
function
When each tile of a columns by rows grid starts, from 0 (first) to 1 (last), in row-major order. diagonal sweeps from the top left, spiral from the outside in, rows top to bottom, and random is a fixed shuffle for a given ...
import { tileDelays } from '@datamoc/mwg/two-d/render';
console.log(tileDelays(3, 2, 'diagonal')); // [0, 1/3, 2/3, 1/3, 2/3, 1]
tileFrame
function
Packs a (sheet, frame) pair into one cell value, for a TileMap built over several sheets - a map whose tiles come from more than one Tiled tileset.
import { tileFrame, tileFrameSheet, tileFrameIndex } from '@datamoc/mwg/two-d/render';
const packed = tileFrame(1, 42); // frame 42 of the second sheet (index 1)
console.log(tileFrameSheet(packed)); // 1
console.log(tileFrameIndex(packed)); // 42
tileFrameIndex
function
The frame half of a tileFrame pack, or the value itself for a plain index.
import { tileFrame, tileFrameIndex } from '@datamoc/mwg/two-d/render';
console.log(tileFrameIndex(tileFrame(2, 5))); // 5
console.log(tileFrameIndex(5)); // 5 - a plain frame index passes through unchanged
tileFrameSheet
function
The sheet half of a tileFrame pack, or 0 for a plain frame index.
import { tileFrame, tileFrameSheet } from '@datamoc/mwg/two-d/render';
console.log(tileFrameSheet(tileFrame(2, 5))); // 2
console.log(tileFrameSheet(5)); // 0 - a plain frame index reads as sheet 0
tileProgress
function
One tile's progress when the whole transition is at progress: tiles start spread across the first stagger of the time, according to their delay from tileDelays, and each then takes the rest of it, so the last tile finishes exactly ...
import { tileProgress } from '@datamoc/mwg/two-d/render';
console.log(tileProgress(0, 0.5, 0.5)); // 1: the first tile is already done
console.log(tileProgress(1, 0.5, 0.5)); // 0: the last one has not started
tileShrink
function
The old picture cut into tiles that each shrink to nothing, in order, uncovering the new scene.
import { tileShrink } from '@datamoc/mwg/two-d/render';
const transition = tileShrink({ columns: 8, rows: 5, order: 'diagonal' });
// Game.current.switchScene(NextLevel, { transition, duration: 0.6 });
console.log(transition.name); // 'tileShrink'
transitionDisposition
function
The rule for a request that arrives while a transition is playing: it applies at once when nothing is playing; while one is playing, a request with no transition finishes it and applies (interrupt, the historical behaviour), a transition ...
import { transitionDisposition, dissolve } from '@datamoc/mwg/two-d/render';
console.log(transitionDisposition({ transition: dissolve() }, true)); // 'ignore'
console.log(transitionDisposition({ transition: dissolve(), queue: true }, true)); // 'queue'
console.log(transitionDisposition({ transition: dissolve() }, false)); // 'apply'
withTextureCanvas
function
Opens a canvas the size of texture, draws it in, and hands the 2D context to paint to make whatever further edits it needs (a palette remap, a composited overlay, a rewritten alpha channel); the result becomes the returned Texture. ...
import { withTextureCanvas } from '@datamoc/mwg/two-d/render';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const texture: Texture2D;
// a minimal custom modifier: invert every pixel's RGB, alpha untouched
const inverted = withTextureCanvas(texture, {}, (context, width, height) => {
const { data } = context.getImageData(0, 0, width, height);
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
...
xpAutotilePattern
function
Which XP pattern a cell's 8-neighbourhood bakes to, for maps computed procedurally rather than read from baked data. sameTerrain is asked once per direction, with (dx, dy) offsets ((0, -1) is north); off the map is the caller's to ...
import { xpAutotilePattern } from '@datamoc/mwg/two-d/render';
const filled = new Set(['0,0', '1,0', '0,1', '1,1', '-1,0', '0,-1', '2,1', '1,2']);
console.log(xpAutotilePattern((dx, dy) => filled.has(`${dx},${dy}`))); // 0 - the interior
console.log(xpAutotilePattern(() => false)); // 46 - isolated on all sides
xpAutotileRef
function
Decodes a raw XP map cell into its autotile image and pattern, or null when the cell holds no autotile reference: EMPTY, a static tileset tile (384 and up), or anything outside the 1-383 autotile range.
import { xpAutotileRef } from '@datamoc/mwg/two-d/render';
console.log(xpAutotileRef(97)); // { index: 2, pattern: 1 } - second image, second pattern
console.log(xpAutotileRef(0)); // null - EMPTY is blank, never an autotile
console.log(xpAutotileRef(384)); // null - the first static tileset tile
two-d/stage
DialogueStage
class
A dialogue scene: a backdrop, characters standing in front of it, and whoever is speaking picked out from the rest.
import { DialogueStage } from '@datamoc/mwg/two-d';
import type { Texture } from 'pixi.js';
import type { SpriteSheet } from '@datamoc/mwg/two-d/render';
declare const backdropTexture: Texture;
declare const heroSheet: SpriteSheet;
const stage = new DialogueStage(1280, 720);
stage.defineCharacter('hero', { sheet: heroSheet, expressions: { neutral: 0, happy: 1 } });
...
StageScript
class
Runs a scene.
import { StageScript, DialogueStage, type StageCommand } from '@datamoc/mwg/two-d';
import { WindowStack } from '@datamoc/mwg/two-d';
import type { Texture } from 'pixi.js';
declare const backdropTexture: Texture;
const stage = new DialogueStage(1280, 720);
const windows = new WindowStack();
const script = new StageScript({ stage, windows, backdrop: () => backdropTexture });
...
StoryScreen
class
A full-screen story beat: a backdrop image, a title and the text over it, advanced by a click - the campaign interlude a scenario shows between maps.
import { StoryScreen, StorySequence } from '@datamoc/mwg/two-d/stage';
const sequence = new StorySequence([{ title: 'Prologue', text: 'The war begins.' }]);
const screen = new StoryScreen({ sequence, width: 960, height: 540 });
console.log(screen.current?.title); // 'Prologue'
StorySequence
class
The beats of a story screen and where the reader is in them, with no renderer attached - the same split TabbedList keeps, so the advance/back/skip rules are testable without a DOM.
import { StorySequence } from '@datamoc/mwg/two-d/stage';
const story = new StorySequence([
{ title: 'Prologue', text: 'The war begins.', music: 'intro.ogg' },
{ text: 'And so it went.' },
]);
story.onMusic.add((track) => console.log(track)); // 'intro.ogg', then null
story.advance();
console.log(story.done); // true, only one page was left
extractDialogueCatalog
function
Collects every say/ask line's text into an identity catalog - each key mapping to itself, the same shape MWL's own extractCatalog builds from _("...")-marked strings - so a translator has real entries to work from instead of a wall ...
import { parseDialogueText, extractDialogueCatalog } from '@datamoc/mwg/two-d';
const commands = parseDialogueText('@alice Hello.\n');
console.log(extractDialogueCatalog(commands).messages); // { 'Hello.': 'Hello.' }
importTwee
function
Reads a .twee file into a StoryScript.
import { importTwee, StageScript } from '@datamoc/mwg/two-d';
declare const tweeSource: string;
declare const script: StageScript;
const story = importTwee(tweeSource);
await script.runStory(story.story, story.start);
parseDialogueText
function
A terse, line-based text format for straight (non-branching) dialogue - the counterpart to twee.ts's passage graphs, for the common case of a scene that is just a sequence of lines. rpg has its own wrapper around the same parsing ...
import { parseDialogueText, StageScript } from '@datamoc/mwg/two-d';
declare const script: StageScript;
const commands = parseDialogueText(`
@alice Hello.
@bob Hi!
- How are you?
- Fine, and you?
`);
...
two-d/ui
- audio
- battle
- bridge
- chess
- cloth
- dialogue
- dungeon
- event-system
- headless
- i18n
- interface
- loading
- movement
- multi-turn-beam
- mwl-presentation
- neural
- pacman
- retro-audio
- save-load
- spring
- string-editor
- village
- world-transition
Bar
class
A filled proportion of a track - a health, mana or experience bar.
import { Bar } from '@datamoc/mwg/two-d/ui';
const hp = new Bar({ width: 120, height: 8, color: 0xcc3333, value: 30, max: 30 });
hp.setValue(18, 30); // took damage
console.log(hp.value); // 0.6
hp.setColor(0xff0000); // and it is bleeding
hp.resize(160, 8); // the window around it grew
BitmapLabel
class
A piece of text baked into a glyph-atlas texture instead of rasterised fresh on every change, the way Label (a thin wrapper over Pixi's Text) does. Label's own doc comment already names the cost this exists to avoid: "wasteful for ...
import { BitmapLabel } from '@datamoc/mwg/two-d/ui';
const fps = new BitmapLabel({ text: '60 fps', size: 12 });
fps.setText('59 fps'); // updated every frame, cheaply, unlike Label
Button
class
A clickable region with idle/hover/pressed/disabled states, a label, an icon, or both.
import { Button } from '@datamoc/mwg/two-d/ui';
const start = new Button({
width: 160,
height: 32,
text: 'Start',
onClick: () => console.log('start pressed'),
});
start.setDisabled(true); // greys it out and stops pointer events
...
Checkbox
class
A square box that is ticked or not, the toggle a settings row or an option list puts beside its label. It draws only the box and its tick - a caption is a Label the game places next to it, so the two can be laid out and translated ...
import { Checkbox } from '@datamoc/mwg/two-d/ui';
const fullscreen = new Checkbox({ checked: true });
fullscreen.onChange.add((on) => console.log('fullscreen', on));
fullscreen.toggle();
DataTable
class
A columned table with a highlighted row, a sort order and paging - the roster or inventory grid that wants headers and a stable sort, where ListView is a flat one-column list.
import { DataTable } from '@datamoc/mwg/two-d/ui';
const roster = new DataTable({
columns: [{ key: 'name' }, { key: 'level' }],
rows: [{ name: 'Ash', level: 3 }, { name: 'Bo', level: 1 }],
});
roster.sortBy('level');
console.log(roster.rows[0].name); // 'Bo'
Dropdown
class
A closed button that opens a list of options - the "option button" a settings row or a scenario picker drops down.
import { Dropdown } from '@datamoc/mwg/two-d/ui';
const difficulty = new Dropdown({
options: [{ id: 'easy', label: 'Easy' }, { id: 'normal', label: 'Normal' }],
});
difficulty.open();
difficulty.move(1);
difficulty.confirm();
console.log(difficulty.selected?.id); // 'normal'
DropdownView
class
The widget for a Dropdown model: a button showing the current choice that opens a column of buttons beneath it, the highlighted row marked with Button.setSelected. Picking a row selects it, closes the list and fires the model's ...
import { DropdownView } from '@datamoc/mwg/two-d/ui';
const level = new DropdownView({
width: 180,
maxVisibleRows: 6,
options: Array.from({ length: 30 }, (_, i) => ({ label: `Level ${i + 1}` })),
});
level.model.onChange.add(({ index }) => console.log('chose', index));
level.model.open();
level.refresh();
...
FloatingText
class
A damage number or a "+1 gold": text that rises and fades over its own lifetime, unlike Label's job of a static, positioned string. This is animation over a label, timed rather than laid out - a game positions one instance per pop-up at ...
import { FloatingText } from '@datamoc/mwg/two-d/ui';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const worldLayer: Container2D;
const popup = new FloatingText({ text: '-12', color: 0xff4444, hold: 0.5 });
popup.position.set(64, 96);
worldLayer.addChild(popup);
popup.update(0.5); // advance the rise/fade by half a second
...
FloatingTextStack
class
Owns the pop-ups a game spawns over world points: one push per number, one update(dt) to drive them all, and no bookkeeping left to the caller.
import { FloatingTextStack } from '@datamoc/mwg/two-d/ui';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const worldLayer: Container2D;
declare const hero: { x: number; y: number };
declare function update(dt: number): void;
const popups = new FloatingTextStack();
worldLayer.addChild(popups);
...
Grid
class
A column/row grid whose tracks are size or grow, the data-driven counterpart to GUI2's [grid]. Fixed tracks take their size; the rest of the space is divided between the grow tracks in proportion to their weight, so a sidebar can ...
import { Grid } from '@datamoc/mwg/two-d/ui';
const grid = new Grid({ columns: [{ size: 200 }, { grow: 1 }], rows: [{ grow: 1 }], gap: 8 });
const cell = grid.rect(0, 1, { x: 0, y: 0, width: 800, height: 600 });
console.log(cell); // the content column: x 208, width 592
HelpScreen
class
A controls-reference or FAQ screen: a list of topics and the body of whichever one is highlighted, over the same ListView/Label a game already builds every other menu from - a recipe, not a new primitive, the way RebindScreen ...
import { HelpScreen } from '@datamoc/mwg/two-d/ui';
const help = new HelpScreen({
width: 320,
height: 200,
topics: [
{ title: 'Movement', body: 'Arrow keys or WASD to move.' },
{ title: 'Combat', body: 'Walk into an enemy to attack it.' },
],
});
...
IconGrid
class
A grid of icons, driven by the keyboard or the pointer.
import { IconGrid } from '@datamoc/mwg/two-d/ui';
import { Shape2D } from '@datamoc/mwg/two-d/render';
const potionIcon = new Shape2D().rect(0, 0, 16, 16).fill(0x66cc66);
const swordIcon = new Shape2D().rect(0, 0, 16, 16).fill(0xcccccc);
const bag = new IconGrid({
width: 160,
height: 80,
columns: 4,
...
Label
class
A piece of text, styled from the theme.
import { Label } from '@datamoc/mwg/two-d/ui';
const gold = new Label({ text: 'Gold: 0', color: 0xffe680 });
gold.setText('Gold: 50'); // skips the re-render if the string is unchanged
gold.setColor(0xff4444); // flash red on a loss, say
ListView
class
A scrolling list of rows, driven by the keyboard.
import { ListView } from '@datamoc/mwg/two-d/ui';
const menu = new ListView({
width: 160,
height: 96,
items: [
{ text: 'Attack' },
{ text: 'Item' },
{ text: 'Flee', disabled: true },
],
...
LoadingScreen
class
A themed loading view driven by LoadQueue.snapshot, with game-owned retry/cancel hooks.
import { LoadingScreen } from '@datamoc/mwg/two-d/ui';
import { LoadQueue } from '@datamoc/mwg/core';
const queue = new LoadQueue();
queue.add({ id: 'tileset', run: async () => { await Promise.resolve(); } });
const screen = new LoadingScreen({
width: 320,
height: 200,
title: 'Loading',
...
MarkdownSpan
interface
Minimal inline markdown for UI text: bold and italic spans, nothing block-level. **bold** and __bold__ render bold, *italic* and _italic_ italic (including the combined ***both***), a backslash escapes a marker (\* stays a ...
import { parseMarkdown, stripMarkdown } from '@datamoc/mwg/two-d/ui';
parseMarkdown('Take **two** *small* coins');
// [{ text: 'Take ', bold: false, italic: false },
// { text: 'two', bold: true, italic: false },
// { text: ' ', bold: false, italic: false },
// { text: 'small', bold: false, italic: true },
// { text: ' coins', bold: false, italic: false }]
stripMarkdown('Take **two** coins'); // 'Take two coins'
MarkupText
class
The canvas backend for inline markup, and the counterpart to RichLabel's HTML text: the same MarkupSpan stream, laid out once by layoutMarkupLines and positionMarkupLines, drawn here as one Text2D per styled run (colour, size, ...
import { MarkupText } from '@datamoc/mwg/two-d/ui';
const description = new MarkupText({ text: 'Pay <b>10</b> gold<img>coin.png</img>', maxWidth: 240 });
description.setText('Pay <b>15</b> gold<img>coin.png</img>');
MarqueeLabel
class
One line of text that stays on one line: when it is wider than width it slides back and forth inside a clipped window (marqueeOffset), driven by update(dt). Under reduced motion it holds still, cut short with an ellipsis, instead of ...
import { MarqueeLabel } from '@datamoc/mwg/two-d/ui';
const title = new MarqueeLabel({ width: 160, text: 'A very long track title that cannot fit' });
title.update(1 / 60); // once a frame from the scene
MessageBox
class
The dialogue box.
import { MessageBox } from '@datamoc/mwg/two-d/ui';
const box = new MessageBox({
width: 400,
height: 120,
pages: [
{ text: 'The old door creaks open. {sound:door-creak.wav}' },
{ speaker: 'Guard', text: 'Who goes there?' },
],
choices: [{ text: 'A friend' }, { text: 'None of your business' }],
...
MessageLog
class
A player-facing message pane: each message is its own block, coloured by its level, and the oldest blocks fall away once the wrapped lines pass maxLines. The classic roguelike "you hit the rat" strip; a Toast is transient and a ...
import { MessageLog } from '@datamoc/mwg/two-d/ui';
const log = new MessageLog({ wrapWidth: 200, maxLines: 4 });
log.add('You find a dewdrop.', 'positive');
log.add('You are starving.', 'negative');
console.log(log.entryCount); // 2
Meter
class
A discrete meter: N icons with a value filled, the Zelda heart row or star rating to Bar's continuous fill. Fractions clip mid-icon through a mask, so halves (or any fraction) work identically for flat shapes and game-supplied textures, ...
import { Meter } from '@datamoc/mwg/two-d/ui';
const hearts = new Meter({ count: 5, value: 3 });
hearts.setValue(2.5); // down to two and a half hearts
console.log(hearts.value); // 2.5
NinePatch
class
A panel that can be resized without distorting its frame.
import { NinePatch } from '@datamoc/mwg/two-d/ui';
import type { Texture2D } from '@datamoc/mwg/two-d/render';
declare const panelTexture: Texture2D;
const panel = new NinePatch(panelTexture, { border: 8 });
panel.resize(200, 120);
console.log(panel.border); // { left: 8, top: 8, right: 8, bottom: 8 }
panel.alpha = 0.6; // fading the panel needs no extra API, unlike width/height/tint above
OnScreenKeyboard
class
An on-screen keyboard: rows of keys as data, rendered through the theme's own Button, each tap pressing its key through pressKey - dispatchText for a typing key, dispatchAction for an action key - so a TextPrompt (or any listener ...
import { OnScreenKeyboard, UPPERCASE_KEYBOARD } from '@datamoc/mwg/two-d/ui';
import type { Container2D } from '@datamoc/mwg/two-d/render';
declare const layer: Container2D;
const keyboard = new OnScreenKeyboard(UPPERCASE_KEYBOARD, { width: 320 });
keyboard.x = 20;
keyboard.y = 240;
layer.addChild(keyboard);
// taps type through core.Input, exactly like a physical key
RadioGroup
class
One choice among several, where a checkbox would allow many: exactly one option is selected, arrows move the selection (which selects at once, the way native radio groups do), and a tap selects directly. Disabled options are skipped by the ...
import { RadioGroup } from '@datamoc/mwg/two-d/ui';
const side = new RadioGroup({ options: [{ text: 'White' }, { text: 'Black' }] });
side.onChange.add((index) => console.log('side', index));
side.select(1);
RebindScreen
class
A ready-made rebinding flow over Input's existing bind/keysFor/actionsForKey, the same way IconGrid is a ready-made inventory screen over Inventory rather than something every game builds by hand. confirm on a row starts ...
import { RebindScreen } from '@datamoc/mwg/two-d/ui';
const screen = new RebindScreen({
width: 200,
height: 120,
actions: ['moveUp', 'moveDown', 'confirm'],
label: (action) => action, // a game usually maps this to a translated string
});
screen.handleAction('confirm'); // starts capturing a new key for the highlighted action
...
RevealState
interface
Progressive text display - the typewriter reveal every dialogue box wants, shared rather than reimplemented per widget. MessageBox pages, Label lines and RichLabel markdown all run on this one character count; what differs is only ...
import { startReveal, advanceReveal, completeReveal, revealComplete } from '@datamoc/mwg/two-d/ui';
const reveal = startReveal('Hello.'.length, 40);
advanceReveal(reveal, 0.5); // 20 characters per half second at speed 40
revealComplete(reveal); // false
completeReveal(reveal); // skip ahead, for a confirm press mid-reveal
revealComplete(reveal); // true
RichLabel
class
A label that renders basic inline markdown (**bold**, __bold__, *italic*, _italic*, combined ***both***) through Pixi's HTMLText, which is what makes mixed styles inside one string possible at all - plain Text styles the ...
import { RichLabel } from '@datamoc/mwg/two-d/ui';
const blurb = new RichLabel({ text: 'Take **two** *small* coins' });
blurb.setText('Take **three** coins'); // skips the re-render if the string is unchanged
ScrollBox
class
A clipped viewport with a scrollbar: put rows in content and they scroll inside the box, everything outside it hidden by a mask. The scrollbar is drawn from the theme; the wheel, scrollBy and scrollIntoView all run through ...
import { ScrollBox } from '@datamoc/mwg/two-d/ui';
const box = new ScrollBox({ width: 200, height: 120, contentHeight: 400 });
box.scrollBy(60);
console.log(box.offset); // 60
ScrollDrag
class
Touch scrolling as plain numbers: feed it pointer positions and it says how far the content moves while the finger is down, then keeps coasting after release with a friction that decays the speed. No pointer events or clock of its own, so ...
import { ScrollDrag } from '@datamoc/mwg/two-d/ui';
const drag = new ScrollDrag();
drag.start(200, 0);
console.log(drag.move(170, 0.1)); // 30: the content follows the finger up
drag.end();
console.log(drag.step(1 / 60) > 0); // true: it keeps coasting
ScrollText
class
A wrapped Label in a ScrollBox that sizes itself: the box is as tall as the text up to maxHeight, and scrolls (wheel, touch, keys, scrollbar) beyond it, so a long hint, credit list or result message never spills over what is around ...
import { ScrollText } from '@datamoc/mwg/two-d/ui';
const hint = new ScrollText({ width: 220, maxHeight: 60 });
hint.setText('Span the gorge with at most 400 of budget. Cables only pull; beams push as well.');
console.log(hint.box.viewportHeight <= 60); // true
SettingsScreen
class
A ready-made settings screen over core.Settings: music and sound-effect sliders, a mute checkbox, a zoom slider, one row per game-defined custom descriptor, a controls page (RebindScreen), and a reset-to-defaults row. Every change ...
import { Settings } from '@datamoc/mwg/core';
import { SettingsScreen } from '@datamoc/mwg/two-d/ui';
const settings = new Settings({ namespace: 'my-game' });
const screen = new SettingsScreen({
settings,
custom: [{ kind: 'boolean', key: 'hints', label: 'Hints' }],
});
screen.handleAction('down');
...
Skins
class
Per-widget and per-state skins, looked up by name - the data-driven half of what GUI2 puts in data/gui/*.cfg, and the thing the one global Theme has no room for.
import { Skins } from '@datamoc/mwg/two-d/ui';
const skins = new Skins();
skins.define('*', 'idle', { background: 0x101018, text: 0xe8e8f0 });
skins.define('button', 'pressed', { background: 0xffe680, text: 0x101018 });
console.log(skins.resolve('button', 'pressed').text); // 0x101018, the override
console.log(skins.resolve('label', 'idle').background); // 0x101018, the wildcard
Slider
class
A draggable knob on a track, for a volume, difficulty or zoom setting.
import { Slider } from '@datamoc/mwg/two-d/ui';
const volume = new Slider({ width: 200, min: 0, max: 100, step: 5, value: 40 });
volume.onChange.add((value) => console.log('volume', value));
volume.setValue(75);
Spinner
class
A small up/down stepper: tap the top half to add one step, the bottom half to take one away. It draws only the arrows; the current value is a Label the game puts beside it, the same split Checkbox and Slider keep from their captions.
import { Spinner } from '@datamoc/mwg/two-d/ui';
const level = new Spinner({ min: 1, max: 99, value: 1 });
level.increment();
console.log(level.value); // 2
StatsScreen
class
A read-only "your stats" display: one label: value line per StatRow, over the same Label every other screen already builds from - a recipe, not a new primitive, the way HelpScreen composes ListView/Label rather than inventing ...
import { StatsScreen } from '@datamoc/mwg/two-d/ui';
const screen = new StatsScreen({
width: 200,
stats: [
{ label: 'Runs completed', value: '12' },
{ label: 'Best depth', value: '7' },
],
});
...
TabbedList
class
The model behind a tabbed, searchable, paged list - an inventory, journal, shop or codex - kept apart from how it is drawn, the same split SelectionModel already draws for plain lists. The caller supplies the tabs and the rows per tab ...
import { TabbedList } from '@datamoc/mwg/two-d/ui';
const items = [
{ name: 'Sword', kind: 'weapon' },
{ name: 'Bread', kind: 'food' },
{ name: 'Shield', kind: 'armor' },
];
const list = new TabbedList<(typeof items)[number]>({
tabs: [{ id: 'all', label: 'All' }, { id: 'weapon', label: 'Weapons' }],
...
TextModel
class
The editing state behind a text field, with no renderer attached - the counterpart to core.Input's onText, which supplies the characters: the DOM gives a key, this decides where it goes, and a game draws value (or maskedValue) with ...
import { TextModel } from '@datamoc/mwg/two-d/ui';
const name = new TextModel({ maxLength: 8, value: 'Hero' });
name.selectAll();
name.insert('Ash');
console.log(name.value); // 'Ash'
TextPrompt
class
A modal single-line text field: a name entry, a save-slot label, a seed.
import { TextPrompt } from '@datamoc/mwg/two-d/ui';
const prompt = new TextPrompt({
width: 320,
height: 140,
title: 'New hero',
message: 'Name your hero (Enter keeps it)',
maxLength: 12,
validate: (value) => (value.trim() === '' ? 'Give them a name first.' : null),
onConfirm: (value) => console.log('named', value),
...
Theme
interface
How the interface looks.
import { highContrastTheme, setTheme, theme, themeChanged } from '@datamoc/mwg/two-d/ui';
//a custom theme: only what differs from the default needs stating (`font` and `color` merge too)
setTheme({
padding: 12,
font: { family: 'Georgia, serif', size: 18, lineHeight: 1.4 },
color: { ...theme().color, text: 0xfff1d0, selection: 0x3a5a8a },
});
//widgets read the theme once, when built: one that must restyle later listens
...
Toast
class
A queued, timed pop-up notification - an achievement unlock, a level-up banner, a "quest complete" toast. The same pop-in/hold/fade-out shape FloatingText already generalizes for a damage number, but queued rather than fire-and-forget: ...
import { Toast } from '@datamoc/mwg/two-d/ui';
import { Text2D } from '@datamoc/mwg/two-d/render';
const toast = new Toast({ fadeIn: 0.2, hold: 1.5, fadeOut: 0.5 });
toast.show(new Text2D({ text: 'Achievement unlocked: First Blood' }));
console.log(toast.isBusy); // true - popping in
toast.update(1 / 60); // called every frame from the scene's own update(dt)
Tooltip
class
The hover explanation every dense interface needs: what this item does, what this stat means, why this button is disabled.
import { Tooltip } from '@datamoc/mwg/two-d/ui';
const tooltip = new Tooltip({ delay: 0 });
tooltip.setViewport(800, 600);
tooltip.hover('Strength: raises attack', 120, 80);
// called every frame from the scene's own update(dt); returns true once, the frame it appears
console.log(tooltip.update(1 / 60)); // true - delay is 0, so it shows on the first frame
tooltip.leave(); // the pointer moved off the stat row
TooltipHost
class
Drives one Tooltip from rectangles instead of from each widget's own pointer events, which is what makes it work in the two places hover cannot reach: on a touch screen (a press that rests for longPress seconds shows the text, and ...
import { Button, TooltipHost } from '@datamoc/mwg/two-d/ui';
const host = new TooltipHost({ longPress: 0.4 });
host.tooltip.setViewport(800, 600);
const test = new Button({ width: 80, height: 28, text: 'Test', disabled: true,
tooltip: () => 'Build a road across the gap first' });
host.addButton(test);
host.pointerMove(10, 10); // over the disabled button: the text still shows after the hover delay
TreeView
class
A collapsible tree, flattened to the rows a game draws - a skill tree, a nested options menu, a directory of scenarios grouped by campaign.
import { TreeView } from '@datamoc/mwg/two-d/ui';
const tree = new TreeView({
roots: [{ id: 'a', label: 'A', children: [{ id: 'a1', label: 'A1' }] }],
});
tree.expand('a');
console.log(tree.rows.map((row) => row.node.id)); // ['a', 'a1']
VerticalLabel
class
Text laid out top to bottom, column by column, right to left - vertical writing, which canvas has no writing-mode for at all. Each character is its own Text, positioned by layoutVertical; this is real per-glyph work, not a flag, which ...
import { VerticalLabel } from '@datamoc/mwg/two-d/ui';
const sign = new VerticalLabel({ text: '出口', columnHeight: 200, size: 24 });
sign.x = 400;
sign.y = 40;
Window
class
A panel with a frame, a title and a content area.
import { Window, Label } from '@datamoc/mwg/two-d/ui';
const confirm = new Window({ width: 200, height: 100, title: 'Leave?', anchor: 'center', blocker: true });
confirm.content.addChild(new Label({ text: 'Progress since your last save will be lost.' }));
confirm.onClose.add(() => console.log('window closed'));
confirm.place(800, 600); // centres it in an 800x600 viewport
confirm.handleAction('cancel'); // closable defaults to true, so this closes it
anchorAlign
function
The horizontal (0, 0.5, 1) and vertical alignment a named anchor implies.
import { anchorAlign } from '@datamoc/mwg/two-d/ui';
console.log(anchorAlign('bottom-right')); // { x: 1, y: 1 }
bitmapLabelStyle
function
the pure style-mapping BitmapLabel builds its underlying BitmapText style from, exported for testing without constructing a BitmapText (which needs a real DOM document, unlike Text)
import { bitmapLabelStyle } from '@datamoc/mwg/two-d/ui';
import { defaultTheme } from '@datamoc/mwg/two-d/ui';
const style = bitmapLabelStyle({ text: 'Score', bold: true }, defaultTheme);
console.log(style.fontWeight); // 'bold'
defaultTheme
const
The palette every widget starts with, before a game calls setTheme.
import { defaultTheme } from '@datamoc/mwg/two-d/ui';
console.log(defaultTheme.color.text); // 0xe8e8f0
console.log(defaultTheme.padding); // 8
ellipsize
function
text cut to fit maxWidth, ending in ellipsis, as measured by measure (the width of a string in whatever font the caller draws it with). A text that already fits comes back unchanged.
import { ellipsize } from '@datamoc/mwg/two-d/ui';
const width = (text: string) => text.length * 10;
console.log(ellipsize('3. Le Fleuve profond (verrouillé)', 150, width)); // 3. Le Fleuve…
fitWindowZoom
function
The zoom a window stack should run at: base, stepped down a whole number at a time until contentHeight (unzoomed) fits viewportHeight, and never below 1. A tall window on a short screen would otherwise be clipped at both ends.
import { fitWindowZoom } from '@datamoc/mwg/two-d/ui';
console.log(fitWindowZoom(3, 300, 700)); // 2 - 300 x 3 is too tall, 300 x 2 fits
floatingTextAgeAtLeast
function
The age a pop-up should report, given the age it has, its lifetime, and the age it is forced to be at least.
import { floatingTextAgeAtLeast } from '@datamoc/mwg/two-d/ui';
console.log(floatingTextAgeAtLeast(0, 1, 0.2)); // 0.2, a fresh line is pushed forward
console.log(floatingTextAgeAtLeast(0.5, 1, 0.2)); // 0.5, an older line keeps the age it has
floatingTextAlpha
function
Opacity at normalised progress t (0 to 1), with hold of that life spent at full opacity.
import { floatingTextAlpha } from '@datamoc/mwg/two-d/ui';
console.log(floatingTextAlpha(0.75, 0.5)); // 0.5
floatingTextRise
function
The rise at progress t, in pixels. Negative: pop-ups travel up the screen.
import { floatingTextRise } from '@datamoc/mwg/two-d/ui';
console.log(floatingTextRise(0.5, 24)); // -12
floatingTextStackMoves
function
The whole stacking policy as arithmetic: which of live (oldest first) share the newcomer's key, where each of them ends up, and what age each is forced to.
import {
FLOATING_TEXT_STACK_GAP,
floatingTextStackLift,
floatingTextStackLifePenalty,
floatingTextStackMoves,
} from '@datamoc/mwg/two-d/ui';
const older = { key: 'hero', x: 0, y: 0, height: 10 };
const newcomer = { key: 'hero', x: 0, y: 0, height: 8 };
...
flowLayout
function
Lays items out left to right and wraps to a new row when the next one would pass width: a toolbar that takes one row on a desktop and two on a phone. An item wider than width sits alone on its row rather than being dropped.
import { flowLayout } from '@datamoc/mwg/two-d/ui';
const buttons = [{ width: 120, height: 32 }, { width: 120, height: 32 }, { width: 120, height: 32 }];
const { positions, height } = flowLayout(buttons, 300, { gap: 8, align: 'center' });
console.log(positions.length, height); // 3 buttons on two rows: 72
highContrastTheme
const
A ready-made high-contrast preset: pure white text on black panels, a thicker border, and a near-opaque modal overlay. Not new capability - setTheme already replaces the whole palette a game reads from, so any high-contrast theme was ...
import { setTheme, highContrastTheme } from '@datamoc/mwg/two-d/ui';
setTheme(highContrastTheme); // an accessibility toggle in the settings screen
layoutMarkupLines
function
Wraps already-parsed spans into lines, word by word, each word measured under its own span's style - which is the acceptance this item was missing: wrapping computed *after* styling, not before it, so a bold or larger run wraps where its ...
import { layoutMarkupLines, parseMarkup } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup('Take <b>two small</b> coins');
const lines = layoutMarkupLines(spans, (piece) => piece.text.length * 8, 100);
console.log(lines.map((line) => line.spans.map((span) => span.text).join('')));
layoutVertical
function
The pure layout math behind VerticalLabel, kept separate from Pixi's Text so it can be tested without a canvas - Text needs a real document to measure glyphs, which does not exist outside a browser.
import { layoutVertical } from '@datamoc/mwg/two-d/ui';
const glyphs = layoutVertical('AB', { lineHeight: 10, columnHeight: 100 });
console.log(glyphs[0]); // { char: 'A', x: -5, y: 5, rotate: true } - Latin letters rotate by default
console.log(glyphs[1]); // { char: 'B', x: -5, y: 15, rotate: true }
markupAccessibilityText
function
The plain-text projection for a place that cannot render any markup at all - a screen reader, a log line, a tooltip that only takes a string. Unlike stripMarkup (which keeps an image span's raw path, useful for round-tripping but not for ...
import { markupAccessibilityText, parseMarkup } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup("Pay <b>10</b> gold<img>coin.png</img>");
console.log(markupAccessibilityText(spans)); // 'Pay 10 gold[image]'
console.log(markupAccessibilityText(spans, { describeImage: () => ' (coin icon)' }));
// 'Pay 10 gold (coin icon)'
marqueeOffset
function
How far a line of textWidth is shifted to scroll inside boxWidth, time seconds in: it waits pause seconds, slides to its end at speed px/s, waits again, then slides back, over and over. A text that fits never moves.
import { marqueeOffset } from '@datamoc/mwg/two-d/ui';
console.log(marqueeOffset(300, 200, 0)); // 0, waiting at the start
console.log(marqueeOffset(300, 200, 3)); // 100, at the far end
console.log(marqueeOffset(150, 200, 3)); // 0, it fits
parseMarkup
function
Reads source as inline markup and returns one span per styled run.
import { escapeHtml, markupToHtml, parseMarkup, stripMarkup } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup("Hail, <b>$name</b>! <span color='#c0ffee'>Four</span> coins<br/>And an<img>gold.png</img>", {
variables: { name: 'Kalenz' },
});
console.log(spans[1]); // { text: 'Kalenz', bold: true, italic: false }
console.log(spans[2]); // { text: 'Four', bold: false, italic: false, color: '#c0ffee' }
console.log(spans[3].text); // '\nAnd an'
console.log(spans[4].image); // 'gold.png'
...
positionMarkupLines
function
Positions already-laid-out lines into a flat list of placed runs, one per span, so a canvas backend draws each span as its own Text2D (or an image as its own sprite) at the reported x/y. This is the half layoutMarkupLines ...
import { layoutMarkupLines, parseMarkup, positionMarkupLines } from '@datamoc/mwg/two-d/ui';
const spans = parseMarkup('Take <b>two</b> coins<img>coin.png</img>');
const measure = (piece: { text: string; image?: string }) =>
piece.image !== undefined ? 16 : piece.text.length * 8;
const lines = layoutMarkupLines(spans, measure, 120);
const runs = positionMarkupLines(lines, { measure, maxWidth: 120, lineHeight: 16 });
console.log(runs.map((run) => run.span.image ?? run.span.text).join(''));
resolveAnchor
function
Places a size-shaped child against the bounds - the anchor half of a data-driven shell, the part GUI2 spells as [cell] anchoring a widget inside its slot.
import { resolveAnchor } from '@datamoc/mwg/two-d/ui';
const bounds = { x: 0, y: 0, width: 800, height: 600 };
console.log(resolveAnchor({ anchor: 'bottom-right', width: 100, height: 30, margin: 10 }, bounds));
// { x: 690, y: 560, width: 100, height: 30 }
scrollOffset
function
Clamps a scroll offset so the content never pulls away from the top or leaves a gap past the bottom. When the content is shorter than the viewport the only offset is 0.
import { scrollOffset } from '@datamoc/mwg/two-d/ui';
console.log(scrollOffset(-5, 500, 200)); // 0
console.log(scrollOffset(900, 500, 200)); // 300
setTheme
function
Replaces the theme; partial objects are merged over the default.
import { setTheme, theme } from '@datamoc/mwg/two-d/ui';
setTheme({ color: { ...theme().color, textHighlight: 0x66ff66 } });
console.log(theme().color.textHighlight); // 0x66ff66
console.log(theme().padding); // 8 - everything else stays at the default
sharpenText
function
Re-rasterises every Text under root at devicePixelRatio times its own on-screen scale, rounded to a whole number. Pixi draws text at the renderer's resolution and knows nothing of a parent's zoom, so text inside a scaled container is ...
import { Container } from 'pixi.js';
import { sharpenText } from '@datamoc/mwg/two-d/ui';
const panel = new Container();
panel.scale.set(3);
sharpenText(panel, 2); // every Text inside now rasterises at 6x
sliceSpans
function
The first count visible characters across already-parsed spans, styles kept - the progressive-reveal slice for RichLabel, where markers must never count toward the total nor leak onto screen half-shown. Counts in string characters, the ...
import { parseMarkdown, sliceSpans } from '@datamoc/mwg/two-d/ui';
sliceSpans(parseMarkdown('Take **two** coins'), 7);
// [{ text: 'Take ', bold: false, italic: false },
// { text: 'tw', bold: true, italic: false }]
sliderFraction
function
Where value sits in [min, max], 0 to 1, clamped. A degenerate range reads 0 rather than dividing by zero.
import { sliderFraction } from '@datamoc/mwg/two-d/ui';
console.log(sliderFraction(30, 0, 60)); // 0.5
sliderValueAt
function
The value at a 0-to-1 position on the track, snapped to step and clamped to [min, max]. The snap is rounded to the step's own number of decimals, so a 0.1 step reads 0.3 rather than 0.30000000000000004.
import { sliderValueAt } from '@datamoc/mwg/two-d/ui';
console.log(sliderValueAt(0.5, 0, 10, 1)); // 5
spinValue
function
Steps a number by delta, snapped to step and kept in [min, max]. With wrap, passing the top starts again at the bottom and dropping below the bottom lands on the top; without it, the value clamps - a level cap or a bag size, where ...
import { spinValue } from '@datamoc/mwg/two-d/ui';
console.log(spinValue(10, 1, 0, 10, 1)); // 10, clamped
console.log(spinValue(10, 1, 0, 10, 1, true)); // 0, wrapped
takeLastEntries
function
The newest count entries of a retained log, oldest first. Pure so the readback contract is testable without a renderer: a count of 0 (or negative) reads nothing, a `count past what is retained reads everything retained.
import { takeLastEntries } from '@datamoc/mwg/two-d/ui';
console.log(takeLastEntries(['a', 'b', 'c'], 2)); // ['b', 'c']
theme
function
import { theme } from '@datamoc/mwg/two-d/ui';
console.log(theme().color.text); // 0xe8e8f0 - the default, until a game calls setTheme
themeChanged
const
Fired with the new theme every time setTheme runs.
import { themeChanged } from '@datamoc/mwg/two-d/ui';
themeChanged.add((next) => console.log('theme changed, text colour is now', next.color.text));
truncateLines
function
text wrapped at maxWidth and cut to maxLines, the last kept line ending in an ellipsis when something was dropped. Words are not split unless one alone is wider than a line.
import { truncateLines } from '@datamoc/mwg/two-d/ui';
const width = (text: string) => text.length * 10;
console.log(truncateLines('build a bridge across the river gorge', 120, 2, width));
// ['build a', 'bridge acr…']
world
EncounterEntry
interface
A weighted encounter table, checked once per step - what decides that walking through tall grass sometimes meets a wild creature and usually does not, and that a rarer species turns up less often than a common one.
import { rollEncounter } from '@datamoc/mwg/world';
const grassland = {
rate: 0.1,
entries: [{ value: 'rat', weight: 3 }, { value: 'bat', weight: 1 }],
};
const encounter = rollEncounter(grassland); // 'rat', 'bat', or null most steps
EnvironmentClock
class
A continuous world clock that maps elapsed time to day phase and weather.
import { EnvironmentClock } from '@datamoc/mwg/world';
const clock = new EnvironmentClock({ dayLength: 600 }); // a 10-minute day
clock.setWeather('rain');
// in the game loop:
const snapshot = clock.update(1 / 60);
console.log(snapshot.phase, snapshot.weather);
Overworld
class
import { Overworld } from '@datamoc/mwg/world';
declare const world: import('@datamoc/mwg/world').World<unknown>;
const overworld = new Overworld();
overworld.add({ id: 'cave', x: 12, y: 4, leadsTo: 'caveLevel1', spawn: 'entrance' });
const location = overworld.get('cave');
if (location) world.enter(location.leadsTo, location.spawn);
SideTurns
class
Which side is taking its turn, which round it is, and what time of day applies - the outer scenario loop. advance() moves to the next side; wrapping past the last side opens a new round and steps the schedule on one entry, cycling. ...
import { SideTurns } from '@datamoc/mwg/world';
const turns = new SideTurns({
sides: ['rebels', 'undead'],
schedule: [
{ id: 'day', lawfulBonus: 25 },
{ id: 'night', lawfulBonus: -25 },
],
});
...
TurnClock
class
import { TurnClock } from '@datamoc/mwg/world';
const clock = new TurnClock();
const poisonId = clock.add({
tick: () => console.log('poison ticks'),
duration: 5,
onExpire: () => console.log('poison wears off'),
});
clock.advance(); // one turn passes; every registered effect ticks
...
World
class
Many maps in one world, joined by transitions.
import { World } from '@datamoc/mwg/world';
interface MyMap { name: string }
const world = new World<MyMap>();
world.define('town', () => ({ name: 'town' })); // persistent by default
world.define('dungeon1', () => ({ name: 'dungeon1' }), { persistent: false });
world.enter('town');
console.log(world.current?.name); // 'town'
alignmentBonus
function
What one alignment gets out of the time of day's own lawfulBonus, in percent: a lawful unit takes it, a chaotic one takes its negation, and neutral and liminal units ignore the time entirely (the reference's own four align= values).
import { alignmentBonus } from '@datamoc/mwg/world';
alignmentBonus('lawful', 25); // 25 - day favours a lawful unit
alignmentBonus('chaotic', 25); // -25 - the same day punishes a chaotic one
alignmentBonus('neutral', 25); // 0 - neutral ignores the time of day
Runnable examples
Each directory under examples/ with an index.html. The built page exists once the example has been built into the site.
-
audio
audio.Music.playTrackscycling three generated tracks with crossfade, andaudio.Sound's pooled one-shot effects -
battle
mwg/battle: a creature battle - species, a type-effectiveness matrix, speed-ordered turns, and a level-up with an evolution check on winning. The damage formula is this example's own invention, not somethingmwgprescribes -
bridge
a bridge-building game on
physics/xpbd'sXpbdWorld: drag beams (or cheaper tension-only cables) between joints within a budget, press Run and a vehicle crosses while every beam is coloured by its loadratio, green to red, and ... -
chess
mwg/board: chess against a small deterministic alpha-beta computer player, with legal moves, check, checkmate, stalemate, castling, en passant, and promotion. Click a square or move a held/repeating arrow-key cursor and press Enter -
cloth
No description in examples/README.md.
-
colour-transform
per-sprite multiply and add, the thing Pixi's tint cannot do, with 4000 individually tinted sprites
-
dialogue
a conversation scene: backdrop, Alice and Bob with expressions, the speaker lit and the other dimmed, and a branching choice. The whole scene is a list of data commands at the top of
main.ts -
dungeon
an SPD-shaped mockup: generated floors, three-state fog of war, bump-to-attack, monsters with their own wander/hunt/flee AI (each judges the hero by its own sight, not the hero's), a secret door hiding a small vault and a hidden trap that ...
-
event-system
GameState+activePage+EventRunner, no map or NPC sprite - a button click stands in for "the player triggered this event". Talking enough times runs acallcommand that hands a real item straight into anactors.Inventory, ... -
headless
mwg/simulation'srunScenario/advanceToInput, with no rendering, map, or sprite at all - the one example that would work identically with no page around it -
hello-world
Game->Scene->update(dt), one sprite moving - the smallest thing mwg can show -
i18n
Catalog,t()interpolation,Intl.PluralRules-backed plurals, andtheme.directionflipping to right-to-left -
interface
windows that stack, keyboard focus going to the top one only, a list with icons and disabled rows, a message box that reveals text and ends on a choice, and a
TextPromptName button on the stack - the reviewed hosting pattern, carrying ... -
loading
core.LoadQueuedriving named, weighted tasks (an asset load, a simulated world generation that fails once on purpose, anassets.AssetStreampreload), withui.LoadingScreenshowing truthful progress, failure, retry and cancel -
minigame
mwg/corescene stacking: a lockpicking timing challenge pauses the room underneath and returns a score throughonResume -
movement
Input.onAction+GridMover+ a wall-tile collision rule over twoTileMaplayers (a full ground layer, a sparse cosmetic decoration layer), isolated fromvillage's dialogue anddungeon's combat, plus a zoomable following camera ... -
multi-turn-beam
mwg/roguelike.MultiTurnBeam: a deterministic beam advances one cell per turn, resolves moving targets when reached, and stops at an opaque blocker -
mwl-content
authored MWL content (
[game]/[map]/[event]/[item]/[unit]/[ai]) compiled at build time bytools/mwl.mjsinto a game-data module, an i18n catalog and an asset manifest, then read by a small game page -
mwl-presentation
No description in examples/README.md.
-
neural
No description in examples/README.md.
-
pacman
an original Pac-Man-style maze chase game with switchable player and ghost AI: human, random, direct-chase, heuristic and offline-trained neural policies. Press P/G to change controllers while the game runs
-
retro-audio
No description in examples/README.md.
-
save-load
core.SaveSystem'ssave/load/delete/list, withoutdungeon's permadeath policy on top -
spring
No description in examples/README.md.
-
string-editor
the string editor as a page: a reference English string beside an editable French translation, with an inline sound marker and a live
{HP_loose}variable, aRichLabelpreview re-rendering as you type, placeholder-drift flagging against ... -
three-d
optional Babylon.js WebGL scene, orbit camera, thin-instanced square and hex terrain with elevation, a continuous heightmap hill, plus mesh and billboard characters
-
tower-defense
mwg/core.Spawnerdriving timed overlapping waves, with a simple 2D path, tower targeting, damage, rewards, and lives -
village
mwg/rpg: an NPC with two conversation pages selected by a switch, a choice that sets a variable, and a short autorun cutscene the first time the map loads. Arrow keys to move, Enter to talk to the shopkeeper -
world-transition
world.World<M>moving the player between two maps - onepersistent(the default, a coin stays collected), onepersistent: false(rebuilt fresh every visit)