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.