A single-page army-list builder engine for tabletop wargames. The whole application is one self-contained HTML file that runs in any browser, online or off — but unlike a normal single-file app, it's assembled at build time from a game-agnostic engine plus one swappable game pack. Ship a different pack and the same engine becomes a different game's army builder.
LaserStorm (2nd Edition) is the first pack. This repo began life as the standalone LaserStorm Force Builder; the engine/pack boundary was carved out of it incrementally (every step proven behavior-identical by golden-master tests), and this is where the multi-game engine now lives.
engine/app.js The game-agnostic shell: state, persistence, containers
(units → task forces → armies → battle groups → forces),
import/export, print, undo, and the UI. Reaches game rules
only through the GAME object the pack defines.
packs/<name>.js One game pack: the GAME namespace — data (traits, units,
classes, factions, org charts), the cost engine, transport
rules, force-org ratios, and the unit schema (stat line +
weapon line, both display and builder-input descriptors).
src/shell.html The HTML skeleton (head, inlined offline fonts/icons, page
markup) with a /*__RALLY_APP__*/ placeholder for the script.
build.mjs Concatenates the chosen pack + engine into the shell →
dist/index.html (one offline file).
dist/index.html The built app. This is what a user opens.
tools/ golden_master.cjs + render_master.cjs (behavior guards),
functional.cjs (stateful-flow assertions: persistence,
undo, delete cascades, import validation, XSS),
build_check.mjs (fast parse + build smoke test),
studio/ (Pack Studio, the in-browser pack authoring UI)
+ studio_functional.cjs (its Playwright suite).
node build.mjs # build with the default pack (laserstorm)
node build.mjs <packName> # build with a different pack
npm run check # parse the combined source + build (fast, no browser)Then open dist/index.html, or serve it: npm run serve (port 3001).
The engine uses inline on* handlers that both call global functions and
assign to module-scope variables (assetPickerSelectedId=…, libSort=…, the
weapon-editor writes, etc.). A closure-bundler (esbuild/rollup IIFE) would
silently break every one of these. Concatenation keeps the whole app in one
shared global scope — exactly the runtime model the code was written for — so
the build is behavior-identical to the pre-split monolith. Moving to a real
module bundler is a deliberate future step, gated on first migrating those
inline handlers to addEventListener/event-delegation. Until then, the pack and
engine are separate source files combined in order (pack first so its
GAME object and alias consts exist before the engine's top-level code reads
them); one scope after concatenation, so the engine's hoisted function
declarations remain reachable from pack closures at render time.
Both golden masters compare observable behavior before/after a change; an empty
diff means nothing changed. Run them against a served dist:
python3 -m http.server 3001 --directory dist &
node tools/golden_master.cjs /tmp/before.json # points engine: every unit,
# ... make your change, rebuild ... # transport matrix, section
node tools/golden_master.cjs /tmp/after.json # limits, premiums
node tools/render_master.cjs /tmp/rbefore.json # card rendering (screen+print)
# ... rebuild ... # + builder-form DOM, gathered
node tools/render_master.cjs /tmp/rafter.json # units, weapon editor, editUnitCompare structurally (key order in gathered-unit objects is not significant):
python3 -c "import json,sys; a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2])); d=[k for k in set(a)|set(b) if a.get(k)!=b.get(k)]; print(d or 'IDENTICAL')" /tmp/before.json /tmp/after.jsonTouching packs/<name>.cost/.org/.transport should move the points golden
master (that's the point); touching rendering or the schema should move the
render master. Neither should change from an engine refactor that's meant to be
behavior-preserving.
The golden masters snapshot math and rendering. The functional suite guards
stateful UI flows the masters can't see — the paths where a bug loses or
corrupts a user's data. It is pass/fail (no baseline file): 12 assertions
covering save/reload persistence, undo, deleteTF/deleteArmy cascades (no
dangling references left behind), army export→import round-trip, import
validation (_parseImportArmyText accepts only correctly-tagged army exports),
crafted-import XSS sanitization (faction icon/color are interpolated into
attributes unescaped and rely on _migrateState to neutralize them),
corrupt-localStorage recovery, and the pack-overridable storage identity
(storage key + app tag resolve, legacy app-tag still imports, and the
migration read adopts data left under the legacy ls_army_builder key).
python3 -m http.server 3001 --directory dist &
npm run functional # -> "functional: 12/12 passed", exit 0 on passRun it after any change that touches state, persistence, delete/import logic, or the escaping helpers.
A pack is a JavaScript file that populates a GAME object (see
packs/laserstorm.js for the complete reference). At minimum it declares
GAME.meta, the stat/weapon schema (GAME.schema), the unit classes,
the cost model (GAME.cost.unitCost — fixed points is the simple case; a
formula is the escape hatch), and the force-organization rules (GAME.org,
GAME.orgCharts, GAME.deployment). Build with node build.mjs <yourpack> and
run the golden masters to see it render.
The engine still contains a thin layer of LaserStorm-shaped assumptions outside the pack (the class/faction/name/size header controls, the traits system, and terminology strings like "Stand" / "Task Force"). Fully generalizing those — and the inline-handler migration that unlocks real module bundling — are the next milestones.
Pack Studio is a browser UI that generates both the pack .js (to commit into
packs/) and a fully built index.html, from forms instead of hand-written
JS. It is authoring tooling — nothing under tools/studio/ ships in the app.
npm run studio # serves the REPO ROOT at 3001
# open http://localhost:3001/tools/studio/
npm run studio:test # Playwright suite (needs the studio served)Serving the repo root matters: the studio fetches engine/app.js and
src/shell.html live, so it can never go stale against the engine (and a
file:// open cannot work — the page tells you). "Load Skirmish example" seeds
a complete project mirroring packs/skirmish.js; start there.
The project JSON is the source of truth. Export it (footer button) and keep it with your game; the generated pack is never parsed back, so regenerate rather than hand-editing pack files. Drafts autosave to localStorage. Preview runs the real app in an instrumented iframe (boot beacon + error reporting); downloads are byte-pristine.
Things worth knowing before you author:
- Cost escape hatch contract. A custom
unitCosthas the signature(unit, ctx)—ctxis reserved and currentlyundefined. Return at least:perStand, unitSize, defaultSize, belowDefault, unitPts, indPts, cmdPts, heroPts, cmdHeroPts, saveDice, allowedRoles, breakdown:{standPts, standComps, weaponPts, weaponComps, mult}— extra fields are preserved. The function MUST be pure per unit: results are memoized perunit.id, so reading army or task-force state silently caches stale values. - Reserved fields (
units[].group/max/unique/availability/tags,cost.currencies,orgCharts[].total/allow, the Instance Fields and most Modifiers effect types) are validated, emitted, and inert — they exist so projects never migrate when the matching engine phases land. - Escape-hatch JS runs as your own code, in your builds and previews only. Imported project files can contain such code — import projects you trust.
- Engine known issues a generated pack inherits (documented, not yet pack-driven): task-force section labels ("Core Units"…) are fixed; the senior/lord commander-rank thresholds are hardcoded at 5/10 task forces; battle-group sizing rules (min group = ceil(largest/2), 3 independents count as 1 unit) are engine-fixed; add-unit limit explanations use LaserStorm phrasing.