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.