Initial spike on the project (via Claude)
README.md

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 optional MASTER_GAIN control exists. Webswing Lite's mixer doesn't provide it, so the client died at startup with an uncaught NPE.
  • 0002-extrapanel-use-lightweight-jlabel — ExtraPanel used a heavyweight java.awt.Label. Webswing Lite supports only lightweight Swing components, so createLabel() threw and aborted the client's PHASE_CHANGE handler, leaving it stuck on "Waiting for the server..." forever.
  • 0003-clientgui-honour-minimap-enabled-preference — ClientGUI called newMinimap.setVisible(true) unconditionally on every board creation, ignoring MinimapEnabled, so the minimap could not be turned off by any preference. Now honours it.
  • 0004-boardview-restore-saved-map-zoom — BoardView persisted the map zoom via GUIP.setMapZoomIndex() but never read it back: zoomIndex and the render scale were 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 with serverInstance = this and AbstractGameManager routes every packet through that static, so a second Server misroutes 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.cache isn'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, Sampler
  • harness/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.sh
  • patches/ — patchset, archive, patched-jar builder
  • scripts/ — bootstrap.sh (one-shot setup), fetch-deps.sh, gen-fontconfig.sh (legacy fontconfig Webswing Lite's toolkit needs), cdp-screenshot.py, summarize.py
  • results/ — 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.