Building a game for iPhone on a machine with no Apple in it
The game core was written to depend on nothing. It turned out to depend on one thing, and the fix was to write a module named after it.
MarbleCore has a comment at the top of its package manifest that has been
there since near the beginning:
Deliberately depends on NOTHING. No SceneKit, no UIKit, no Metal. This is what lets the physics run in
swift testin milliseconds and what makes the renderer swappable. Keep it that way.
That claim was very nearly true. The physics, the level generator, the camera,
the modes, the rides and the music all import exactly one thing: simd. Which
is Apple’s.
So the whole portable core was portable in the way a boat is seaworthy right up until you check whether it has a bottom.
The fix is a module named simd
Linux Swift ships the generic SIMD protocol and the SIMD3<Float> types in the
standard library. What it doesn’t ship is Apple’s simd module — the free
functions, simd_length, simd_normalize, simd_cross and friends.
Rather than edit every source file to hide the import behind #if canImport,
the package adds a target on Linux only, and names that target simd:
#if os(Linux)
let marbleCoreDependencies: [Target.Dependency] = ["simd"]
let compatibilityTargets: [Target] = [
.target(name: "simd", path: "Sources/SimdCompat")
]
#else
let marbleCoreDependencies: [Target.Dependency] = []
let compatibilityTargets: [Target] = []
#endif
import simd now resolves to a hand-written file of about 150 lines that
implements the subset the game actually uses, each function a small generic loop
over scalarCount. Not one line of production source changed. On Apple
platforms the target doesn’t exist and the real simd is imported as always.
There is a test target that checks the stand-in agrees with the real thing, which is the part that makes this survivable rather than merely clever.
What Linux can and cannot check
./tools/check-linux.sh builds the core, runs its tests, and then renders
images. What that validates: physics, generated geometry, camera math, modes,
rides and music.
What it can’t: rendering, input, audio sessions, haptics and face tracking. All of those live in the app targets, which need Xcode, and they are deliberately outside the check rather than stubbed into it. A check that pretends to cover the parts it can’t reach is worse than one with a stated edge.
A second renderer, so the check has something to look at
A build that compiles and a build that draws the level correctly are different
claims. MarbleCIRenderer exists to make the second one checkable without a
GPU: it takes the same baked MeshData triangles the app consumes, projects
them through the game’s own orthographic camera model, and resolves visibility
with a software depth buffer.
No SceneKit. No GPU. No window server. No browser. No image library.

MarbleCIRenderer at 360×780. No GPU was involved and no display server was running.That last one is the interesting constraint, because a renderer that can’t write a file isn’t much use to CI.
The PNG that has never been compressed
There is no libpng and no zlib in this pipeline, so RasterRenderer writes the
PNG itself: IHDR, IDAT, IEND, a hand-rolled CRC-32 over each chunk, and a
hand-rolled Adler-32 over the pixel data.
The clever part is the part that refuses to be clever. A PNG’s image data has to be a valid zlib stream, and writing a compressor is a real project. But the DEFLATE format includes a “stored” block type that compresses nothing at all — so the encoder emits a two-byte zlib header, then walks the scanlines in chunks of 65,535 bytes, writing each one as a stored block with its length and complement, then the checksum:
var compressed = Data([0x78, 0x01])
var offset = 0
while offset < scanlines.count {
let count = min(65_535, scanlines.count - offset)
compressed.append(offset + count == scanlines.count ? 1 : 0)
appendLittleEndian(UInt16(count), to: &compressed)
appendLittleEndian(~UInt16(count), to: &compressed)
compressed.append(contentsOf: scanlines[offset..<(offset + count)])
offset += count
}
appendBigEndian(adler32(scanlines), to: &compressed)
Every file it produces is therefore exactly the same size — 843,308 bytes — regardless of what is in the picture, because the size depends only on the dimensions. Eight completely different scenes, eight identical byte counts.
Out of curiosity I ran the image above through an actual PNG compressor and compared the decoded pixels. Identical, and 5,136 bytes — a ratio of about 164 to 1. That is the version on this page, because shipping the raw one to you over the network would be rude. It is also a fair measure of exactly how much work the encoder is not doing, which is the entire reason it fits in one file with no dependencies.
Turning the level into pictures instead of triangles
The other half of the pipeline is marble-diagnostics, which reads the same
generated level and writes SVG. It’s the view you want when the question is
“what did the generator actually build”, not “what does it look like”.

The same pass also projects the level through the game’s isometric camera as flat shapes, which is the quickest way to see whether a layout reads before committing to lighting it:
And because a tunnel ride is a thing you debug with a graph rather than a screenshot, it writes one of those too, plus the underlying samples as CSV — ride distance, world position, camera elevation, yaw, pitch, scale, boom distance, near clip and speed multiplier.
y = -2. The long flat middle where orange sits
under blue is the underwater passage — and the camera diving far below the
marble through all of it is the shot, not a bug.It reproduces, which is the whole point
Every artifact on this page came from re-running the generators today, on a working tree that has moved on since those commits. Five of the outputs have counterparts committed on 25 August — the map, the ride profile, the ride CSV and two of the rendered scenes. All five came back byte for byte identical, SVG, CSV and PNG alike.
That is what makes any of this useful in CI. A screenshot test whose output drifts is a screenshot test you turn off within a month.
One caveat worth stating plainly: this run was on macOS. Both generators are written to run on either, and the Linux script is the one that gates the build — but “I re-ran it and it matched” is a macOS claim here, not a Linux one.
The obligatory hour lost to something unrelated
Both scripts carry the same twenty lines of defensive shell, and it has nothing to do with Swift the language:
Swiftly 1.0.0 — the toolchain manager — can leave a CoreFoundation run loop
alive after the command it delegated to has already finished, inside restricted
containers. The build finishes, the tests pass, and the script simply never
returns. So both scripts check whether swift is really the swiftly shim, and
if it is, walk the toolchain directory to find and invoke the actual binary
underneath. SWIFT_BIN overrides the whole dance.
Every portability story has one of these in it. The interesting engineering was a hundred and fifty lines of vector maths and a PNG encoder that doesn’t compress; the thing that actually ate the afternoon was a process that wouldn’t exit.