mm-spike #
Feasibility spikes for running MegaMek as the tactical engine behind a browser-based, ATProto-backed campaign game.
A 2v2 against Princess plays end-to-end in a browser via Webswing Lite rendering the stock MegaMek Swing client. See TODO.md for what's next and GUIDELINES.md for licensing, trademarks and IP.
Webswing Lite, not Webswing. We use Webswing Lite, the AGPL-3.0 community fork by manticore-projects continuing from the last open-source Webswing release (v20.2.5). We have no affiliation with the commercial Webswing product at webswing.org. The fork keeps the upstream repository name, artifact names and
webswing.*config keys, so the two are easy to confuse — credit for the original architecture goes to the Webswing team.
Constraint #
Minimal, upstreamable patches. Everything builds against an unmodified release jar, the harness uses only public API, and all four patches that exist are latent MegaMek bugs rather than adaptations. A new MegaMek release should be a recompile plus at most a patch refresh, never a rebase.
Quickstart #
./scripts/bootstrap.sh # ~1.1GB of downloads, then builds everything
./spike-webswing/start.sh # blocks; Ctrl-C tears everything down
Open http://localhost:8080/megamek/. You play TraineeA, Princess plays TraineeB; press "Done" in the lobby. Exits on victory.
AUTO_READY=<seconds> forces the human ready server-side, for automated runs
that need the deploy phase without a UI click.
Two people can run side by side by picking different ports and an instance name, which namespaces the generated configs and logs:
INSTANCE=claude PORT_WS=8090 PORT_MM=8860 ./spike-webswing/start.sh
./spike-webswing/stop.sh claude
Stop instances with stop.sh, never pkill -f spike-webswing/start.sh — that
pattern matches every instance on the box and will kill someone else's live game.
start.sh publishes results/<instance>.pid with the process group ids of its
children, and stop.sh signals exactly those.
Still shared between instances: MegaMek's mmconf/ (both seed the same
clientsettings.xml, so identical content) and logs/.
One browser per instance; restart the instance between runs, not just the
browser. Webswing Lite keeps the client JVM alive after the page closes. If a second
client connects while the first holds TraineeA, the server renames it
TraineeA.2 — that player owns no units and has no team, so controls are greyed
out and Player Settings throws an NPE. Webswing Lite session settings do not help:
maxClients: 1 refuses the second browser, and CONTINUE_FOR_USER +
allowStealSession lets it evict the first and locks out both.
What bootstrap does #
Nothing third-party is committed — the repo is ~40 files. bootstrap.sh fetches
the pinned JDK 21, the MegaMek 0.51.0 release and source tarball, and Webswing
Lite 26.4.5; then builds the harness, applies the patchset into
MegaMek-patched.jar, generates the fontconfig, and primes MegaMek's
units.cache. It is idempotent, so re-run it freely.
Host requirements: curl, tar, unzip, sha256sum, and fontconfig
(fc-match). Without fontconfig the client dies at startup with
"Fontconfig head is null" — bootstrap warns rather than failing, so the message
is easy to miss.
Pre-commit hooks #
Hooks are managed by prek and configured in
prek.toml. One-time setup:
uv tool install prek
prek install --prepare-hooks
They then run on every commit; prek run --all-files runs them by hand.
Patches #
Unified diffs in patches/patchset/, applied -p1 against a pristine tree —
never copied source files, which would silently discard upstream changes on the
next release.
patches/build-patched-jar.sh copies the touched files to a scratch dir, applies
the patchset, compiles only those, and injects the classes into a copy of
MegaMek.jar. The MegaMek trees are never modified. (Injection rather than
classpath shadowing because the jar is sealed.)
Applied, and verified to be the only differences from the stock jar:
0001-sound-guard-optional-master-gain-control—Sound.setVolume()assumed the optionalMASTER_GAINcontrol exists. Webswing Lite's mixer doesn't provide it, so the client died at startup with an uncaught NPE.0002-extrapanel-use-lightweight-jlabel—ExtraPanelused a heavyweightjava.awt.Label. Webswing Lite supports only lightweight Swing components, socreateLabel()threw and aborted the client'sPHASE_CHANGEhandler, leaving it stuck on "Waiting for the server..." forever.0003-clientgui-honour-minimap-enabled-preference—ClientGUIcallednewMinimap.setVisible(true)unconditionally on every board creation, ignoringMinimapEnabled, so the minimap could not be turned off by any preference. Now honours it.0004-boardview-restore-saved-map-zoom—BoardViewpersisted the map zoom viaGUIP.setMapZoomIndex()but never read it back:zoomIndexand the renderscalewere both hardcoded, so a saved zoom was discarded and every board opened at 1.00x. Now initialises both from the preference.
patches/archive/ holds patches tried and dropped, with reasoning.
Findings #
- ~730MB RSS per concurrent match (median of 8 runs, 616–770MB) — flat across heap cap (384m–2g) and match size (2v2 vs 4v4). The cost is per-JVM overhead, not game state, so smaller matches are not cheaper.
- One game per JVM.
Server's constructor ends withserverInstance = thisandAbstractGameManagerroutes every packet through that static, so a secondServermisroutes the first game's traffic. Data is isolated; transport isn't. Patching the packet path was necessary but not sufficient. Archived, because one match per container sidesteps it. - CPU is not the constraint — 8 concurrent games used 2.2 of 16 cores.
units.cacheisn't shipped: 2.9s to load a prebuilt one vs 20.2s to build it from 10,989 files. Bake it into the image.- jlink runtime is 59MB vs 346MB, and runs a 4v4 fine.
- Headless works under Webswing Lite — no Xvfb needed.
Layout #
harness/src/bench/—MMBench(N concurrent games, memory sampling),IsolationCheck,Samplerharness/src/spike/—HostForHuman(server + bot, one slot open for a human),WatchGame+TextRenderer(observer client dumping full text state; the adapter-pattern prototype)spike-webswing/— Webswing Lite + MegaMek settings templates,start.shpatches/— patchset, archive, patched-jar builderscripts/—bootstrap.sh(one-shot setup),fetch-deps.sh,gen-fontconfig.sh(legacy fontconfig Webswing Lite's toolkit needs),cdp-screenshot.py,summarize.pyresults/— bench CSVs and jstacks kept as evidence for Findings
Benchmarks #
./scripts/bootstrap.sh
./harness/run.sh --games 8 --settle-round 3 --csv results/out.csv
python3 scripts/summarize.py results/out.csv
harness/run.sh defaults to the stock jar; set MM_JAR for the patched one.