⚓ Seaworth Games
Rotp-Fusion → Web Stack review 25 Aug 2026

Does this stack survive ROTP?

The skeleton does. Vite and TypeScript are right. But three of the template's four load-bearing assumptions break against a 247,000-line Java codebase — and the two hardest problems in that codebase aren't mentioned at all.

Line by line

The suggested stack, judged against what's actually in the repo

PieceVerdictWhy
Vite Keep Nothing about ROTP argues against it. Asset volume will need attention, but that's config, not architecture.
TypeScript Keep Non-negotiable at this size. The Java model is deeply typed and the type system is what makes a transliteration checkable.
React Recast Right for the ~15 non-map screens — ship design, colonies, races, council, options are forms and lists. Wrong for the galaxy map, and wrong as the place game state lives.
Game state in useState Replace Immutable spread-per-action collapses at ROTP's state size. Needs a mutable core the UI subscribes to.
DOM grid board Replace The map is 1,000+ systems with fleets, flight paths, nebulae, labels and range overlays. Canvas, not divs.
Framer Motion Drop for now Solves DOM layout transitions. ROTP's animation problem is sequenced coroutines drawn onto a canvas. Different problem.
localStorage saves Replace Saves are a full object graph autosaved twice per turn. IndexedDB plus a hand-written versioned serializer.

What you're actually porting

Rotp-Fusion at HEAD, Java 17, seven small dependencies, zero tests

247klines of Java
822 files
132kin model/
430 files
106kin ui/
356 files
48kof AI
6 implementations
1.2 GBraw assets
webp/ogg after build
0tests
no CI in repo

Two numbers reframe the project. The AI is 48,000 lines across six full implementations — it's the most valuable thing in the repo and the reason anyone plays Fusion rather than base ROTP. And ui/ is 43% of the code, almost all of it hand-drawn immediate-mode Graphics2D: 138 paintComponent overrides, ~1,300 manually positioned strings, 315 hand-declared hit rectangles. There is no widget tree to map onto the DOM.

Where the template breaks

Four assumptions that don't survive contact with the code

State container

The state object is too big to copy per action

The doc's model is setState(s => ({...s, units: s.units.map(...)})). In ROTP, Empire is a single 4,065-line class with ~90 fields, and each empire carries a SystemView[] sized to every system in the galaxy. Live state is O(empires × systems). Galaxy sizes run to 1,000 systems on Massive and 10,000 on Insane.

Empire.java 4,065 LOC · ~90 fields · SystemInfo sv = SystemView[nSystems]
SystemView.java 1,029 LOC — one per empire, per system
Colony.java 3,166 LOC · Galaxy.java 904 · GameSession.java 1,533
Instead Mutable TypeScript classes in core/, mirroring the Java graph including its ID-indirection (sysId, empId resolved through Galaxy — that part is already flat and port-friendly). Bump a version counter on mutation; React subscribes with useSyncExternalStore. React renders the state, it never holds it.
Rendering

The galaxy map is a painted scene, not a grid of elements

GalaxyMapPanel is 1,529 lines that draw every system, sprite, flight path, nebula and label into one offscreen buffer and blit it once per frame, with zoom thresholds gating level of detail. There's no spatial index and no culling beyond a visibility check — it already runs paint-bound on the JVM.

One specific gap has no Canvas2D equivalent: empire ship-range borders are built with java.awt.geom.Area boolean unions, unioned across a thread pool and cached. Canvas has no path boolean operation. Plan for an offscreen alpha mask you outline, or a polygon-clipping library — don't discover this in month three.

Instead Canvas2D for the map, React/DOM for everything else. Preserve the IMapHandler seam verbatim — five screens already share one map renderer through ~70 predicate hooks, and it's the best-factored thing in the UI layer.
Animation

Animations are coroutines living inside the model

Framer Motion animates elements between layout states. ROTP animates by blocking: TechShipWeapon.java grabs a live Graphics2D off the UI and draws a beam frame by frame with sleep() and paintImmediately(), outside the paint cycle entirely. Combat stacks do the same. This is simulation and presentation fused into one call stack.

27 files call sleep(…) · 12 files call paintImmediately(…)
heaviest: TechShipWeapon (8) · DemoShields (8) · CombatStackShip (4)
Instead An await-based timeline over the canvas — await beam(from, to, 400) reads almost exactly like the Java, which makes transliteration mechanical. Framer Motion stays available for HUD and screen transitions; it just isn't load-bearing, so leave it out of the proof of concept.
Persistence

Saves won't fit in localStorage, and there's no schema to copy

A save is the whole object graph through Java serialization, deflated into a zip, written twice per turn by autosave. localStorage is ~5 MB and synchronous. Worse, there's nothing to port: CURRENT_SAVE_VERSION is declared and never referenced anywhere. Compatibility is serialVersionUID pinning plus dead fields kept alive purely for stream shape, with post-load repair scattered across a dozen validateOnLoad() methods.

Instead IndexedDB, and a hand-written serializer with a real version number from day one — this is the one place where not copying the Java is strictly better. Decide early whether reading existing .rotp files matters; if it does, that's a Java-serialization parser in TypeScript and it should be scoped as its own project.

The two hard parts nobody wrote down

Not in the stack doc — and these are what determine whether the port works

Hard part one

The turn loop stops mid-phase to ask the player questions

GameSession.nextTurnProcess() runs on its own thread and suspends inside the simulation to wait for a human — pick a tech, bombard this planet?, colonize?, answer this diplomatic message, vote in council. It's implemented as a spin-wait on a volatile boolean, polled every 200 ms, released from the Swing thread.

There are 21 suspension points in RotPUI alone, plus more raised from inside ShipCombatManager. A single-threaded JavaScript runtime has no equivalent of any of it.

Java today

// turn thread
pauseNextTurnProcessing("tech");
RotPUI.selectTechPanel();
waitUntilNextTurnCanProceed();
// ↑ sleep(200) until the
//   Swing thread flips a flag

Web equivalent

// core/turn.ts — no UI import
const tech = await ask({
  kind: "selectTech",
  options: available,
});
// UI resolves the promise
// when the player clicks
Decide this first Model the turn as an async sequence that yields requests and awaits responses, from the very first commit. It's the one decision that is a rewrite rather than a refactor if you get it wrong, and it's also what keeps the core worker-ready and — later — server-ready without another pass.
Hard part two

The model imports the UI, and a straight port inherits that

There is no clean core to lift. StarSystem, ShipFleet, Nebula and Empire implement a Sprite interface whose contract is literally draw(GalaxyMapPanel, Graphics2D) — the domain objects are the view objects. Roughly 500 of StarSystem's 1,203 lines are rendering and mouse handling. Two complete Swing screens live inside model/empires/species/.

Underneath it all sits rotp.util.Base: a 1,960-line interface with 223 methods, implemented by 173 classes, that hands every model object both galaxy() and drawShadowedString().

166 / 430 model files import rotp.ui.*
112 / 430 model files import java.awt or javax.swing
59 calls to RotPUI.instance() from inside rotp.model
cleanest package: model/incidents — 57 files, zero AWT
Instead Enforce the boundary mechanically, not by convention — dependency-cruiser or an ESLint boundary rule in CI from commit one: nothing under core/ may import React, the DOM, or anything under ui/. It's a two-hour setup that decides whether you end up with a portable simulation or a second copy of the same tangle.

The proof of concept

A vertical slice that stresses the risky decisions, in dependency order

  1. Galaxy generation and a canvas map. 200 systems, pan and zoom, zoom-gated detail, one race. Port GalaxyShape plus StarSystem with all the drawing stripped out.

    Proves the render path and the model/view split
  2. The turn engine, with real suspension points. Wire in two that actually block — select-tech and the colonize prompt — through the request/response channel rather than a callback.

    Proves the highest-risk architectural bet
  3. Colony economics for the player only. The five spending categories, population growth, industry, factories. Colony.java is 3,166 lines and is representative of the transliteration work ahead.

    Proves the port rate is tractable, and gives a real estimate
  4. Fleet movement and arrival. Deploy, travel, arrive, colonize — the ID-indirection graph end to end.

    Proves the object graph survives the translation
  5. A parity harness. Patch the Java build to dump game state as JSON headlessly after each turn; run the TypeScript core from the same seed and diff 100 turns. This needs the RNG reimplemented exactly — a serializable copy of SplittableRandom, plus JDK Random's LCG where Collections.shuffle is used. Both are small and worth doing precisely.

    Turns "looks about right" into a regression suite — the single most valuable artifact of the PoC
Deliberately out of scope All 48k lines of AI (stub it), ship combat, diplomacy and the council, 15 of the 16 races, 21 of the 22 languages, and audio. Each is a known quantity once the core holds; none of them tests anything the five steps above don't already test.

One cheap experiment worth an afternoon

A baseline, not an alternative

CheerpJ 4.1 is a WebAssembly JVM that runs AWT and Swing in the browser, with Java 17 support in preview — which is exactly what Rotp-Fusion targets. Point it at the existing -mini jar and you may have ROTP running in a browser tab this week.

That is not the port. You get no web codebase to build on, the asset payload is brutal, and every piece of "other work that follows from it" would still be blocked. But as a live reference implementation to diff behaviour against — and as a same-week answer to "can this run in a browser at all" — it costs an afternoon and might save you an argument.

What I need from you

Five answers that change the plan

  • Save compatibility. Does the web version need to read existing .rotp files? A "yes" adds a Java-serialization parser to the critical path.
  • Target galaxy size. Designing for 200 systems and designing for 10,000 are different renderers and arguably different state layouts.
  • Fidelity bar. Faithful port with parity testing, or "ROTP-like, freed to diverge"? This decides whether step 5 of the PoC exists at all.
  • Multiplayer later? If yes, the core needs to be authoritative and serializable from day one — cheap now, expensive to retrofit.
  • Touch and mobile? The whole UI assumes hover, right-click, drag-select and a 1229×768 design resolution scaled by screen height.