Building a game for iPhone on a machine with no Apple in it
The core was written to depend on nothing. It depended on one thing, and the fix was a module named after it.
MarbleCore has carried this comment at the top of its package manifest almost
from the start:
Deliberately depends on NOTHING. No SceneKit, no UIKit, no Metal.
Very nearly true. Physics, level generator, camera, modes, rides and music
import exactly one thing: simd. Which is Apple’s.
A module named simd
Linux Swift ships the generic SIMD types in the standard library, but not
Apple’s simd module — simd_length, simd_normalize,
simd_cross and friends. Rather than hide the import behind #if canImport in
every source file, the package adds a Linux-only target and names it 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 about 150 hand-written lines covering the subset
the game uses, each function a generic loop over scalarCount. No production
source changed, and on Apple platforms the target does not exist. A test target
checks the stand-in agrees with the real thing.
What the check covers
./tools/check-linux.sh builds the core, runs its tests, then renders images:
physics, generated geometry, camera math, modes, rides and music. Not rendering,
input, audio sessions, haptics or face tracking — those need Xcode, and
are left outside the check rather than stubbed into it.
A second renderer
MarbleCIRenderer makes “does it draw correctly” checkable without
a GPU. It takes the same baked MeshData triangles the app consumes, projects
them through the game’s orthographic camera, and resolves visibility with
a software depth buffer.
No SceneKit. No GPU. No window server. No browser. No image library.

MarbleCIRenderer at 360×780, with no GPU and no display server.A PNG that has never been compressed
With no libpng and no zlib, RasterRenderer writes the PNG itself: IHDR, IDAT,
IEND, a hand-rolled CRC-32 per chunk and a hand-rolled Adler-32 over the pixel
data.
The image data has to be a valid zlib stream, and writing a compressor is a real project — but DEFLATE includes a “stored” block type that compresses nothing. The encoder emits a zlib header, walks the scanlines in 65,535-byte chunks writing each as a stored block, 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 is therefore exactly 843,308 bytes whatever is in the picture, because size depends only on dimensions. Eight scenes, eight identical byte counts.
Run one through a real PNG compressor and the decoded pixels come back identical at 5,136 bytes — about 164 to 1. That is the version on this page, and a fair measure of how much work the encoder is not doing.
The level as pictures
marble-diagnostics reads the same generated level and writes SVG — the
view you want when the question is “what did the generator build”
rather than “what does it look like”.

The same pass projects the level through the game’s isometric camera as flat shapes — the quickest way to see whether a layout reads before committing to lighting it.
A tunnel ride is debugged with a graph rather than a screenshot, so it writes one of those too, plus the samples as CSV.
y = -2. The long middle where orange sits under blue is the
underwater passage; the camera diving below it is the shot, not a bug.It reproduces
Every artifact here came from re-running the generators today, on a working tree that has moved on since those commits. Five have counterparts committed on 25 August — map, ride profile, ride CSV and two rendered scenes — and all five came back byte for byte identical. A screenshot test whose output drifts is one you turn off within a month. Caveat: this run was on macOS, so it is a macOS claim, not a Linux one.
The unrelated hour
Both scripts carry twenty lines of defensive shell, none of it about Swift.
Swiftly 1.0.0 can leave a CoreFoundation run loop alive after the delegated
command finishes inside restricted containers: the build finishes, the tests
pass, and the script never returns. Each script therefore checks whether swift
is really the swiftly shim and walks the toolchain directory for the binary
underneath. SWIFT_BIN overrides it.
The interesting engineering was 150 lines of vector maths and a PNG encoder that does not compress. What ate the afternoon was a process that would not exit.