by game developers, for game developers

Features

Most of mwg is the shape you'd expect from any 2D framework: sprites, tile maps, stat blocks, save slots. This page is the other part - the specific, non-obvious mechanisms that exist because a real problem came up while building one of the reference games, and the fix turned out to be a small, reusable primitive rather than a one-off patch. Each one names the problem it solves, the trick that makes it work, and a short example of using it.

mwg/mwl

Author content once, compile it before play

Maps, units, items, events, translations, and asset paths should not be scattered through a game's TypeScript. That makes content harder to review and forces the browser to carry a parser it does not need.

MWL is a WML-inspired, game-neutral authoring layer. The mwl build pipeline reads .mwl files and emits compiled game data, an i18n catalog, and an asset manifest. The game keeps its own rules through hook references, while the runtime consumes generated data directly. Try the MWL example.

[unit_type]
  id=Spearman
  hitpoints=36
  movement=5
[/unit_type]

npm run mwl -- build content/ -o generated/

mwg/core

Declarative reactions, not an if cascade

Without a way to react to a stat crossing a threshold, a game ends up writing its own branch cascade - one if per reaction, scattered through the update loop, harder to keep straight as more reactions pile up.

ReactionTable checks a list of named when/action rules against any state shape, firing action the instant when turns true and staying quiet while it stays true - edge-triggered, so a still-true condition never re-fires every frame. A once rule retires after firing; the default re-fires on the next rising edge. The same table watches a character's stats or a plain object's own fields (an item's durability, a door's open flag) - nothing about it assumes an actor.

const reactions = new ReactionTable([
  { id: 'critical', when: s => s.hp / s.maxHp <= 0.25,
    action: () => playLowHpAlarm() },
  { id: 'shattered', when: s => s.hp <= 0,
    action: () => breakItem(), once: true },
]);
reactions.check({ hp: 20, maxHp: 100 }); // ['critical']

mwg/two-d/render

Per-sprite colour transform: multiply and add

Pixi's built-in tint only multiplies a sprite's colour, so it can darken but never pull toward a colour - a poison-green tint or a white hit-flash is a lerp, which needs an add term tint alone cannot express.

ColorTransformBatcher rides an extra vertex attribute through Pixi's existing sprite batch, computing texel × M + A in the batch shader itself - one vertex attribute per sprite, not a render-texture filter pass per sprite. Every batcher/high-shader detail this needs is confined to this one file by project convention, so a Pixi upgrade that breaks the assumption fails a pinned test loudly instead of mis-rendering silently.

sprite.colorTransform = { multiply: 0xffffff, add: 0x552200 };
// a slow burn: unaffected saturation, warmed toward orange

mwg/actors

A barrier that always breaks outermost-first

A shield cast on top of an already-standing ward should break before the ward underneath does, regardless of which order they were cast in - a property a plain stat modifier can't express, since it has no notion of layers.

Barrier holds independent layers stacked in cast order, each with its own decayPerTick; absorb(amount) drains the most-recently-added layer first and spills into the next once it empties, so "last cast, first broken" holds without the game tracking cast order itself.

barrier.add(20);       // a standing shield
barrier.add(10, 2);     // a ward, decaying 2/tick, cast after
barrier.absorb(15);     // drains the ward first, then the shield

mwg/actors

One track shape for talent trees and gear slots

A subclass talent tree and "equip one of three armor abilities" read as unrelated systems - until the actual difference between them turns out to be only what a point buys, not how points are earned or spent.

Advancement types each tier 'points' | 'branch' | 'capstone': most tiers add to a spendable ledger, one tier forces a permanent, mutually-exclusive choose(tier, option, level) that throws on re-choice, and a late tier is a single capstone pick. What a point or a choice actually grants is left entirely to the game.

mwg/actors

Item affixes routed by trigger, curse derived not tracked

Enchantment systems usually end up hard-coding what "flaming" or "cursed" means inside the item system itself, coupling item data to combat logic that should own that interpretation instead.

An AffixDef carries only a trigger ('strike' | 'defend' | 'passive'), an optional kind restriction, a weight, and a curse bit; matchesContext decides whether it should fire, and what it does stays game code. applyAffix/ removeAffix derive the item's cursed flag from whatever affix is currently installed, so swapping a cursed affix for a benign one can never leave an item stuck locked.

mwg/roguelike

Streaks and combos as one turn-window primitive

Kill streaks, combo counters, and "stays stealthed as long as you keep landing sneak attacks" are the same shape - a count that extends within a window and resets outside it - usually reimplemented separately for each.

TriggerTracker.trigger(turn) extends the streak when turn - lastTurn <= window, otherwise restarts it at 1; isActive(turn) checks whether the window still holds without triggering it. mwg supplies only the window arithmetic - what a streak length unlocks is entirely the game's own design.

mwg/i18n

Bidi isolates, because Canvas text ignores direction

Canvas 2D's fillText always resolves bidi runs as if the paragraph were left-to-right - nothing reads context.direction - so a right-to-left string with a leading digit or an interpolated Latin name gets its runs misordered on screen.

mwg's t() wraps a resolved right-to-left string in the Unicode bidi-isolate controls RLI/PDI (U+2067/U+2069) - a formatting-control fix, not a locale heuristic, so it holds regardless of the canvas's own settings. typographic() applies real French Imprimerie-nationale spacing (a thin no-break space before ;:!?, around guillemets) and Duden German number/unit spacing, idempotently, so calling it twice never doubles a space.

mwg/i18n + mwg/two-d/ui + mwg/audio

A hit message is text, sound, and layout wearing one string

A combat line is never just characters: it carries a variable, emphasis on two words, and a sound cue - and the translator editing it sees none of that structure, so a dropped token reaches the player as a raw {HP_loose}.

One semantic message renders several ways from one catalog entry: the log line, a compact HUD number, an accessibility announcement, a debug dump, and the audio channel holding the sound path itself. Placeholders take Python f-string specs ({HP_loose:02d}), markdown spans (*hit*, **die**) draw through RichLabel and reveal progressively without leaking markers, and {sound:hit.wav} plays at its visible position during a MessageBox reveal. diffPlaceholders fails the translation check when the French line drops a token. Try the string editor.

new MessageBox({
	 pages: [{ text: t('player.hit.log', { HP_loose: 12 }) }],
	 onSound: (path) => sounds.get(path)?.play(),
});

mwg/battle

A bounded stage ladder as one modifier, not one per stage

A naive "+1/-1 stat stage" system adds a new modifier on every stage change, leaving stray modifiers behind unless the game remembers to remove exactly the right ones - and stage count and applied multiplier can quietly drift apart.

StatStages.change(stat, delta) clamps to ±max and replaces the single tracked modifier for that stat, so the stage and its multiplier can never disagree; resetAll() clears every stage at once for a switch-out. The stage-to-multiplier curve itself is a function the game supplies, not a fixed table mwg imposes.

mwg/core

Save obfuscation honestly scoped to what it actually stops

Every "encrypted save" scheme built on a simple XOR is only ever obfuscation, not real encryption - a distinction games routinely blur, then get called out for by the first player who reads the source.

Scramble's own doc comment says outright that it is not encryption and must never be presented as such to a player - its only job is stopping casual save-file editing in a text editor, accepting the classic known-plaintext XOR weakness on purpose rather than pretending otherwise. unscramble throws on a wrong key via a fatal TextDecoder instead of silently handing back garbage.

mwg/roguelike + mwg/world

Two clocks, deliberately not one reused twice

Turn order and elapsed-time effects look similar enough that it's tempting to build one "timing" abstraction and stretch it to cover both - until the two questions turn out to need different answers.

roguelike.Scheduler answers "who acts next": cost divided by a per-actor speed, sorted by time then insertion order for reproducible tie-breaks. world.TurnClock answers a different question - how many turns have elapsed - and ticks long-running effects (hunger, poison) with an optional duration and onExpire, independent of who acted this turn. Kept as two small types instead of one overloaded one.