Backend environment for match hosting for lance.blue
Shell 53%
Java 20%
JavaScript 8%
Python 8%
CSS 4%
Dockerfile 4%
1%
HTML 1%
Module Management System <1%

README.md

arena #

The match container for lance.blue. One image, one game: a MegaMek server, Princess for every unclaimed faction, and the real MegaMek Swing client rendered into a browser by Suramadu.

Ported from the spike repo, where a 2v2 against Princess played end-to-end in a browser. This repo packages that into a container image.

Suramadu, not Webswing. We use Suramadu, the AGPL-3.0 community fork by manticore-projects of Webswing v20.2.5 — called "Webswing Lite" before the 26.4.6 rename. We have no affiliation with the commercial product at webswing.org. Its internals still use webswing names; see LICENSING.md for which ones are deliberate.

Quickstart #

Docker is the only requirement. There is no host build.

./scripts/build.sh       # build the image
./scripts/run.sh         # play a match; open http://localhost:8080/ and
                         # pick the seat — one client app per seat, at /<did>
./scripts/test.sh        # lint, syntax, unit and compile checks
./scripts/push.sh        # build and publish; REGISTRY overrides the default

run.sh starts the real image with a generated launch manifest and no upload targets, so it exercises the same entrypoint, init, watch and exit scripts a real match does.

The ~1.1GB of third-party downloads goes into a BuildKit cache mount, not into the image and not into the repo. It is fetched once per machine and reused by every later build, including across branches.

DEVELOPING.md covers working on a single build stage, and how to clear the caches.

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.

Layout #

Organised by the thing you are working on, not by file type. If you are chasing a rendering bug, everything MegaMek-shaped is in one directory.

megamek/ configuring and patching MegaMek: the nine patches, client settings, game options, logging config, units.cache priming, prune-data.sh — one documented line per thing removed from the shipped data, and why a match cannot reach it — and skin/ — lanceBlueSkin.xml, the lance.blue board backgrounds and window icons, and the install script that checks every image the skin references exists
suramadu/ configuring Suramadu: its server config, Jetty properties, the fontconfig generator its toolkit requires, web/ — the themed loading page served in place of the war's — and patches/, applied to the frontend bundle inside the war at build time
src/ our own Java — MatchHost, MatchWatcher, TextRenderer — and the script that jars it
container/ process management inside a running container: entrypoint.sh, init/, watch/, exit/, lib/
tests/ the suite, plus the benchmark and screenshot helpers that are not part of it
scripts/ what you type
Dockerfile assembles the above; also where the downloads happen
versions.env every pinned version and checksum

The repo groups config templates by component; the image collects them into one /opt/arena/config/, because the container scripts want a single directory.

Build stages mirror the same split — jre, megamek, suramadu, arena — so a failure names the component, and the stages that do not depend on each other build in parallel.

How a match runs #

Headquarters launches a task with one input: a presigned URL for a launch manifest, which names the scenario, the player slots, their ATProto DIDs and where to upload results. Nothing else about the match is read from the environment; ARENA_PROFILE and its two companions are the one diagnostic toggle. The contract is docs/launch-manifest.md in the headquarters repo.

entrypoint.sh
  ├── init/    manifest → identity → assets → rendered config
  ├── suramadu Suramadu on :8080, serving the MegaMek client
  ├── host     arena.MatchHost — the MegaMek server, Princess, one game
  ├── watcher  arena.MatchWatcher — observer client spooling turn reports
  ├── watch/   uploads reports while the match is still being played, and
  │            times out a match nobody is playing
  └── exit/    result, images and any deferred uploads, on victory *or* signal

The container exits when the host exits. Suramadu and the watcher are support processes; if either dies the match keeps running.

Reaching victory does not end the process. The host writes the result, touches a marker file for the exit scripts, and stays up for a configurable linger period so players can read the victory screen and the end-of-game images can be collected. The spike exited five seconds after the result, so the page died with no summary.

A match nobody is playing is the opposite problem, and container/watch/idle.sh ends it. The host writes state/host.activity — a timestamp refreshed by every sign of play: a human joining or returning, the game advancing a round or a phase, a unit taking its turn. When the timestamp goes limits.idleTimeoutSeconds stale, the watcher emits an idle-timeout callback and sends the entrypoint the same SIGTERM Fargate would, so the container takes the ordinary shutdown path and finalize still uploads whatever there is. One rule covers the lobby nobody joins, the lounge nobody starts, and the game everyone left or stopped playing; a finished game is exempt, because the host is deliberately lingering then. A manifest that omits the limit, or sets it to 0, gets no timeout.

Rules defaults #

Two files are seeded into MegaMek's mmconf/ before every launch, and they are the whole of what arena says about how a match plays. megamek/clientsettings.xml.template is one player's client — what is docked, what is drawn, what makes a noise. megamek/gameoptions.xml.template is the rules, for everyone in the match: MegaMek's game options, listed only where they differ from upstream's defaults.

Two differ today. Ejected crews leave the field the moment they get out, rather than staying as units their owner then moves every round — MegaMek's own ejected_pilots_flee, which some of the shipped scenarios already set for themselves. And a unit that cannot fire — shut down, crew out — gets no firing turn, which is the choice MegaMek already makes for the physical phase.

Both are defaults, not rules arena imposes. A scenario that names a GameOptionsFile of its own is loaded with that file and none of this, so a scenario author still decides their own match.

Patches #

Nine, in megamek/patches/patchset/. Each is a MegaMek bug rather than an adaptation to our environment, so each should go upstream. megamek/verify-patches.sh runs on every build and checks that the patched jar differs from stock in exactly these classes and no others.

  • 0001 sound — Sound.setVolume() assumed the optional MASTER_GAIN control exists. Suramadu's mixer does not provide it; the client died at startup.
  • 0002 extrapanel — ExtraPanel used a heavyweight java.awt.Label. Suramadu supports only lightweight Swing components, so the client hung forever on "Waiting for the server...".
  • 0003 minimap — ClientGUI force-called setVisible(true) on every board creation, so MinimapEnabled could not turn the minimap off.
  • 0004 zoom — BoardView persisted the map zoom but never read it back, so every board opened at 1.00x regardless.
  • 0005 entityimage — EntityImage built a GraphicsConfiguration in a static initializer, so without a screen the class died with HeadlessException on first load and every later use threw NoClassDefFoundError: camo never rendered and the image work re-ran uncached on every entity update.
  • 0006 fov cache — with FoV darkening on, BoardView threw away every cached hex composite on every selection change, even when the position the shading looks out from had not changed — including deployment, where the unit being placed is not on the board yet and the shading draws nothing at all. Every visible hex was recomposited (and, under Suramadu DirectDraw, PNG-encoded) for an identical result. The patch clears only when the FoV viewer actually changed. clientsettings.xml.template now seeds FovDarken off, so this only bites a player who turns the shading back on from the View menu — the patch stays because that toggle exists.
  • 0007 sprite tearing — Sprite.imageUpdate() ran prepare() on the AWT image fetcher thread, repainting the sprite's buffer in place while the EDT could be painting — and, under Suramadu DirectDraw, eagerly PNG-encoding — the same buffer, capturing half-painted sprites. The patch marshals it to the EDT, where every other prepare() caller already runs.
  • 0008 headless accelerated images — the one-arg ImageUtil.createAcceleratedImage(Image) returned null in a headless JVM, which erased unit base images mid-build in the bot and observer clients; the resulting NPE aborted SENDING_ENTITIES handling so later entities never got their force ids. The patch gives it the same headless BufferedImage fallback the two-arg overload already had.
  • 0009 camo overlay — EntityImage loaded its six facing camo overlays in one loop inside one try, so a single unreadable overlay left that facing and every later one all-zero. applyColor() reads zero as a multiply-by-zero, so those facings drew a black silhouette with no camo while earlier facings were fine. The patch gives each facing its own try, falls back to the neutral 128 the overlay-off path uses, and reads with ImageIO instead of ImageIcon plus PixelGrabber so the load cannot abort or take an interrupt.

Three more in suramadu/patches/, applied by suramadu/patch-war.sh to the frontend bundle inside the Suramadu war. suramadu/verify-embed.sh runs on every build and checks the served bundle — including its precompressed copies — actually carries all three:

  • 0001 keyboard latency — the stock frontend parks keydown events behind a 100ms timer and only flushes its input queue on keyup or a 100ms interval, while mouse events send immediately. The patch sends keyboard input on the same fast path. Measured: keydown-to-socket 33–134ms down to ~21ms.
  • 0002 mouse flush — coalesced mouse input waited up to 100ms for the flush interval, so hover feedback and drags paid it twice over. The patch drops the interval to 20ms in both mouse and touch mode. Measured: input-to-socket p90 under 5ms.
  • 0003 paint ack — the stock frontend acknowledged a frame only after painting it, so the render sat inside the server's send loop. The patch acks on receive, letting the next frame travel while this one paints.

Constraints #

  • One game per JVM. Server's constructor ends with serverInstance = this and every packet routes through that static. Running one match per container avoids the problem; see megamek/patches/archive/multitenancy/.
  • ~730MB RSS per match, flat across heap cap and match size. The cost is per-JVM overhead, not game state, so smaller matches are not cheaper.
  • units.cache is not shipped by MegaMek — 20.2s to build, 2.9s to load. The image bakes it.
  • The jlink runtime is 60MB versus 346MB for the full JDK.
  • The container is never the auth boundary. MegaMek has no per-player auth; the always-on WebSocket proxy in front of the task is. Task IPs are never exposed.

Licensing #

Non-commercial fan project. Three rules: publish our changes, stay non-commercial, keep the control plane a separate process. See LICENSING.md, and read it before adding a dependency.

Status #

A match launched by headquarters played through to victory on Fargate on 2026-08-03, which also exercised the upload path for the first time. Latency work since then is measured and shipped — PERFORMANCE.md has the numbers.

What the finish leaves behind is the weak part: victory drops players into an empty lobby rather than holding the victory screen, and the end-of-game images are unproven. A match showing no sign of play — never joined, never started, or abandoned mid-game — now shuts itself down at limits.idleTimeoutSeconds rather than billing a full task lifetime, though that has not yet been seen happen on Fargate. See TODO.md.