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

Would Swift be easier?

Yes — materially. All three things that made the web port risky are substantially solved by the platform rather than by you. Swift buys back the threading model, the state scale, and the drawing API. What it costs is every machine that isn't a Mac — and two silent determinism traps the JVM doesn't have.

Re-scoring the web port's three blockers

Same codebase, different target — this is the whole argument

Turn loop suspends on human input 21+ points where simulation stops to ask the player something
Web
High
Swift
Low
State graph too big to copy O(empires × systems), up to 10,000 systems
Web
High
Swift
Non-issue
Drawing API mismatch 106k LOC of immediate-mode Graphics2D, no widget tree
Web
Medium-high
Swift
Low
Run-to-run determinism Reproducible games, and parity against the Java build
Web
Low
Swift
Medium — new

The risk doesn't disappear, it moves. Three-quarters of it moves off the architecture and onto a shortlist of language semantics — which is a much better place for it to be, because language semantics can be fixed once, centrally, before you port a single class.

The stack

What I'd build it on, and where the fashionable answer is the wrong one

PieceCallWhy
Swift 6, SPM package + thin Xcode app Yes Keep the simulation in a plain SPM library target with no AppKit import — same boundary discipline the web review argued for, and it's what keeps every later option open.
AppKit NSView + Core Graphics Yes NSView.draw(_:) is a direct paintComponent analogue and CGContext is the closest living relative of Java2D. This is the short path.
SwiftUI Chrome only Menus, preferences, the settings system, save/load dialogs. Not the map, not the hand-drawn screens — reaching for Canvas there trades a 1:1 API match for a redraw model you'd fight.
async/await + actors Yes The answer to the turn loop. See below — but adopt it carefully, it's also the one place Swift 6 will fight you.
Core Text Yes Real typographic metrics for the ~1,300 manually positioned strings. Better fidelity than the browser's measureText.
Codable + versioned envelope Yes A type-checked schema where the Java has none. Write to a document package, not a single blob.
SwiftData / Core Data No You have a simulation, not a record store. Persistence here is a snapshot, and a graph database in the middle of a turn loop is a liability.
AVFoundation With a transcode AVFoundation doesn't read Ogg Vorbis, which is what the existing Maven pipeline emits. Retarget it at AAC or CAF; the 264 MB of uncompressed WAV is the same win either way.
ImageIO Yes Decodes WebP natively, so the existing cwebp -q 75 build step carries over unchanged.
SpriteKit / Metal Not yet Nothing in the current renderer needs a scene graph or a GPU. Keep it as the escape hatch for the procedural planet spheres if profiling asks for it.

What the platform hands you

Three problems that were yours to solve on the web and aren't here

Concurrency

The turn loop stops being an architecture problem

On the web, the 21 suspension points inside nextTurnProcess() forced a redesign: no threads, so the turn had to become a yielding state machine before a single line of simulation could be ported. In Swift you have both real threads and structured concurrency, and the suspension points map to await one for one.

The Java is a spin-wait on a volatile flag polled every 200 ms. The Swift is the thing that pattern was clumsily approximating.

Java today

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

Swift

let tech = await ask(
  .selectTech(available)
)
// UI resumes the continuation
// when the player clicks
Note You could even keep the Java shape verbatim for a first pass — a background thread and a DispatchSemaphore is the same design done properly. I wouldn't ship it, but it means this is no longer a decision that gates the port.
Scale

The state container stops being a design at all

Reference-semantic classes, real inheritance (Empire extends Species survives intact), no immutability tax, no 5 MB storage ceiling, no worker boundary, no serialization across a thread. An Empire holding a SystemView per system, times ten thousand systems, is unremarkable on a Mac with real memory.

Everything the web review said about mutable cores, version counters and useSyncExternalStore simply evaporates. You port the object graph as written.

Rendering

Core Graphics is a closer match to Java2D than Canvas is

Both are immediate-mode 2D APIs built on paths, transforms, clips, composite modes, gradients and strokes — the port is close to mechanical. Two specifics matter:

  • Path booleans exist. CGPath.union(_:) (macOS 13+) directly replaces java.awt.geom.Area for empire ship-range borders. On the web that had no equivalent at all and needed an alpha-mask workaround.
  • Retina scaling is free. You draw in points and get 2× pixels. The Java pre-derives a Font[201] table and scales everything by screenHeight / 768 — most of that machinery can just be deleted.

Custom Paint/PaintContext (the round gradients, the sphere shading) map to CGGradient and CGShading. The per-pixel work in ImageColorizer and Sphere2D maps to vImage or a small Metal kernel if it's ever hot.

What Swift makes harder

Four hazards, and the first two are silent

Hazard one

Two determinism traps that don't exist on the JVM

Swift's Dictionary iteration order is randomly seeded per process. Java's HashMap order is arbitrary but stable for a given insertion sequence, so ROTP has been quietly relying on it for years. Port HashMap to Dictionary and the same seed produces a different game every launch — on the same machine, with no error and nothing to catch it.

Swift's sort() is not stable. Java's Collections.sort is guaranteed stable (TimSort). Every tie — fleet ordering, target selection, tech lists, incident processing — resolves differently, again silently.

69 files use HashMap (334 sites) · 38 files under model/ + ai/ declare HashMap or HashSet fields
~87 keySet/values/entrySet iteration sites inside rotp/model
160 Collections.sort calls across 63 files · 174 Comparator references
Fix it once, up front Write an insertion-ordered dictionary and a stable sort, and make them the blanket substitution during transliteration — never a case-by-case judgement about whether this particular map "matters". Adding a tiebreak on entity ID to every comparator is a reasonable belt-and-braces second layer. Cheap now; effectively unfindable later.
Hazard two

Integer overflow traps instead of wrapping

The RNG is a serializable copy of JDK SplittableRandom, and its mixing functions are pure wrapping 64-bit arithmetic — z = (z ^ (z >>> 30)) * 0xbf58476d1ce4e5b9. In Swift that traps and crashes on the first call. Every operation in there needs &*, &+ and an explicit unsigned type.

The good news is the blast radius: unsigned right shift appears in just two files, 22 sites, essentially all of it inside the RNG. This is a contained, one-afternoon problem — as long as you know about it before you write the class rather than while debugging turn one.

Hazard three

Swift 6 strict concurrency vs. an unguarded shared graph

The Java shares its entire mutable object graph between the turn thread and the Swing thread with essentially no synchronisation — synchronized appears in 5 files, volatile in exactly one. Swift 6's language mode makes that shape a compile error, and the obvious fix (isolate the model in a GameActor) is the wrong one: the renderer needs synchronous access to thousands of live objects every frame, so actor isolation forces a snapshot layer you don't want and the Java never needed.

Recommendation @MainActor-first. Model and turn loop both on the main actor; the awaits at the suspension points are exactly where the run loop wants to breathe anyway. Offload only the AI planning pass, with a Task.yield() between empires so a long Xilmi turn doesn't freeze the window. If that proves noisy, port in Swift 5 language mode and adopt strict concurrency as a separate pass.
Hazard four

Optionality, and float parity you can't fully have

247,000 lines of implicit Java nullability have to become explicit Swift Optionals. It's mechanical but it's everywhere, and it's the main reason this is a translation rather than a transliteration. Budget for it as a constant tax rather than a milestone.

Separately: bit-exact parity against the Java build is not fully achievable. sqrt is IEEE-exact, but pow, sin and friends are not specified bit-for-bit, and ROTP calls them from game logic — 130 Math.pow sites across 51 files, 185 Math.sqrt across 48. StrictMath, which would be reproducible, is used in exactly one file.

Consequence for the parity harness Diff with a tolerance rather than on equality, and treat divergence as a signal to investigate rather than a test failure. Where a specific game outcome hinges on a transcendental, port that one call site against StrictMath's algorithm. This applies to the web port too — it just matters more here, because Swift is otherwise close enough that exact parity feels within reach.

Things that get quietly nicer

Small wins, but they compound across 822 files

  • Protocol extensions are a near-exact fit for Java's default methods — 614 of them across 92 files, including the 223 on Base. Port the mechanism verbatim; just split Base into a game-context protocol and a drawing helper instead of reproducing a 1,960-line god-interface.
  • The ID-indirection is exactly what Codable wants. sysId and empId already stand in for object references throughout, which is the standard workaround for encoding a graph with shared nodes. The Java's worst habit turns out to be the port's best asset.
  • Resource loading is centralised — getResourceAsStream appears at 8 sites in 3 files. The Bundle rewrite is an afternoon, not a sweep.
  • Assets stop being a download. 1.2 GB raw is a shipping problem on the web and a non-event in a .app. Keep the existing WebP pipeline; only the audio needs retargeting off Ogg.
  • Dependencies mostly evaporate. commons-math and commons-lang become Foundation and the standard library, jna-platform becomes ordinary AppKit, webp-imageio becomes ImageIO, and xchart becomes Swift Charts — which is an upgrade, not a substitution.
  • iPad becomes one UI layer away. A 4X game arguably belongs on a tablet, and a Swift core reaches it far more cleanly than a web build ever will — the entire ROTP interface assumes hover, right-click and drag-select.

The cost, stated plainly

The part that isn't a technical judgement

You give up reach. The web version runs on every machine including Macs; this one runs on Macs. That's the trade, and no amount of architecture makes it go away.

The sharper point is that the two ports share nothing but this analysis. A TypeScript core and a Swift core produce no common artifact — not a file, not a test, not a data format. This is a fork in the road, not a sequence, and doing both means doing the 247,000 lines twice.

Which makes one option worth a spike, if reach matters to you at all. Swift 6.2 made WebAssembly an officially supported target. A pure-Swift core with no AppKit import, an AppKit front end on the Mac, and a Wasm build with a canvas front end on the web is a genuine one-core, two-front-ends architecture — and it's the same boundary discipline you'd need anyway. The caveats are real: binary size, Foundation coverage, and being early. Worth a day to prove the core compiles to Wasm before you commit to the shape. Not worth planning around until it does.

What changes in the proof of concept

Same five-step slice as the web review, re-costed

  • Step 0, new and non-negotiable: the ordered dictionary, the stable sort, and the wrapping-arithmetic RNG. Perhaps 300 lines. Everything after this depends on them existing, and retrofitting them means re-verifying every ported class.
  • Step 1 (galaxy + map) gets easier. CGContext is close enough to Graphics2D that this is mostly transcription, and the range-border problem is a library call.
  • Step 2 (turn engine) gets much easier and stops being the thing the PoC exists to de-risk. Keep it in — it still shapes the API — but it's no longer the bet.
  • Step 3 (colony economics) is unchanged and becomes the most useful step, because it's now the honest measure of the port rate. Colony.java is 3,166 lines and representative; time it and multiply.
  • Step 5 (parity harness) is unchanged in value but loosened in method — tolerance-based, per the float note above. Its real job here is catching the determinism traps, and for that it's more valuable than it was on the web, not less.
Still 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.

What I need from you

Different questions than the web review asked

  • Is this instead of the web port, or as well? If "as well", the honest answer is to pick one and finish it — or spend the day on the Swift-to-Wasm spike first and let that decide.
  • Who plays it? If the audience is you and a handful of people who own Macs, the reach argument mostly stops mattering and Swift is the better project.
  • Does iPad factor in? It's the strongest thing on Swift's side of the ledger and it's invisible if you're only thinking about the desktop.
  • Is bit-exact parity with the Java a requirement or a comfort? A requirement turns the determinism work into the critical path. A comfort makes it a one-week hardening pass.
  • How much of Fusion do you actually want? Six AI implementations and a custom-species DNA system are a large fraction of the remaining work. Base ROTP with one good AI is a much smaller target and might be the real goal.