Two separate projects, both rendering through SpriteKit rather than SDL - see "Why SpriteKit, not SDL3" below for how this replaced the port's original SDL3-on-Apple-platforms plan:
Junkbot.xcodeproj-Junkbot-macOSandJunkbot-tvOSapp targets.JunkbotMobile.swiftpm- an iOS App Playground (opens in both Xcode 15+ and the Swift Playgrounds app). iOS used to be a third Xcode target here (Junkbot-iOS) - moved out to its own Playground so it can be opened/run directly in Swift Playgrounds on iPad, not just Xcode. See "The iOS Playground" below.
- Shared with
ports/SDL3/ports/SDL2(file references pointing directly at../SDL3/Sources/JunkbotSDL3/*.swiftinJunkbot.xcodeproj; symlinks inJunkbotMobile.swiftpm, the same techniqueports/SDL2already uses to share files withports/SDL3):Screens.swift,TextRenderer.swift,GameRender.swift(per-frame world/menu rendering -renderWorld/render/VirtualCursor/TextureCache),GameInput.swift(mouse/touch-to-world coordinate handling -handleMouseDown/Move/Up),MenuFocus.swift(keyboard/d-pad menu focus navigation -moveFocus/directionPressed/nudgeDrag/activatePressed/activateReleased- extracted fromports/SDL3'sInput.swiftsince none of it touches SDL/GameController directly).Renderer.swift(theGameRendererprotocol),Color.swift, andButton.swiftmoved intoSources/JunkbotCoreitself (they had zero SDL-specific imports) - both this project andJunkbotMobile.swiftpmget them for free viaimport JunkbotCore, no file reference/symlink needed anymore.main.swift/Input.swiftare not shared - both are genuinely SDL-specific (the blocking SDL event loop, raw SDL gamepad polling); Darwin has its ownGamepadInput.swiftinstead (see below) providing the samehideOSCursor/warpCursor/notePointingInputseamMenuFocus.swiftcalls into. - Darwin-only (
Sources/JunkbotDarwin/, shared betweenJunkbot.xcodeprojandJunkbotMobile.swiftpmvia symlinks into the latter):SpriteKitRenderer.swift(theGameRendererconformance - see below),GameShell.swift(the globals every shared file expects a port to provide:repoRoot,gameEngine, camera state,levelCatalog- the Darwin equivalent of the top ofports/SDL3'smain.swift),Audio.swift(realSoundBoard/MusicPlayer,AVAudioPlayer-backed - see "Audio" below),GamepadInput.swift(GameController.framework-backed gamepad/keyboard input - see "Gamepad + keyboard input" below),GameScene.swift(theSKScenesubclass drivinggameEngine.tick()/render()from SpriteKit's ownupdate(_:)callback, plus mouse/touch/stick input),GameViewController.swift(hosts theSKView/JunkbotScene- shared between the tvOS target'sAppDelegate_tvOS.swift, which sets it aswindow.rootViewControllerdirectly, and the iOS Playground's SwiftUIApp, which wraps it in aUIViewControllerRepresentableinstead),AppDelegate_macOS.swift(AppKit window/SKViewsetup, macOS target only),AppDelegate_tvOS.swift(tvOS-only now - see above). images/,font/,levels/: Xcode folder references (blue folders, preserving subdirectory nesting) inJunkbot.xcodeproj's Copy Bundle Resources phase; plain symlinks intoJunkbotMobile.swiftpm/Sources/JunkbotMobile/declared asresources:in itsPackage.swift(SwiftPM resource paths can't escape the target directory with../, unlike Xcode's folder references, so the symlinks have to live insideSources/itself). Either way,Bundle.main.resourceURL(GameShell.swift'srepoRoot) sees the exact same layout the SDL ports read via plain paths.audio/is deliberately not bundled raw on Darwin - see "Audio" below.Junkbot.xcodeprojuses a local Swift Package reference to the repo root, consuming theJunkbotCoreproduct.JunkbotMobile.swiftpminstead picks itsJunkbotCoredependency conditionally inPackage.swiftbased on#if os(macOS): a localpath: "../.."dependency when the manifest is parsed on a Mac (Xcode - edits toSources/JunkbotCoreshow up immediately), or.package(url: "https://github.com/colemancda/junkbot-swift.git", branch: "main")everywhere else - the Swift Playgrounds app on iPad/iPhone only has this.swiftpmbundle itself, sandboxed, with no siblingports//Sources/directories apath:dependency could resolve against, so it needs to fetchJunkbotCorefrom GitHub to build standalone. Note#if os()here checks the platform actually running the manifest (Xcode's loader vs. Swift Playgrounds' own on-device one), not the platform being built for.
SpriteKitRenderer.swift implements the same GameRenderer protocol SDL3Renderer/
SDL2Renderer implement, using SpriteKit purely as an immediate-mode blitter:
- No
SKPhysicsBody/SKPhysicsWorldanywhere.GameEngine/JunkbotCoreremains the sole simulation authority, exactly as on every other port -GameScene.swift'supdate(_:)only ever callsgameEngine.tick(). - Each draw call (
fillRect/drawTexture/fillTriangle/etc.) adds a freshSKNodeto the scene;clear()removes every node added by the previous frame. This reimplements the same "redraw the whole command list every frame" modelSDL3Renderer/SDL2Rendereralready use, on top of a fundamentally retained scene graph, via node churn rather than a persistent node hierarchy. - Texture handles:
GameRenderer's texture type isOpaquePointer(chosen for SDL2's opaque C-struct import limitation - seeSources/JunkbotCore/GameRenderer.swift's doc comment). SpriteKit has no such constraint (SKTextureis a perfectly nameable Swift class), so textures are tracked in a side table keyed by a small integer handle, andOpaquePointer(bitPattern:)wraps that integer purely to satisfy the protocol's type signature - the bit pattern is never dereferenced, only round-tripped back throughInt(bitPattern:)to look the texture up. - Coordinate system: SpriteKit is Y-up with the origin at the scene's bottom-left; the
GameRendererprotocol (and every other backend) is Y-down, origin top-left.flippedY(_: height:)converts at every draw call, including insideSKTexture(rect:in:)sub-region clipping math (which is itself Y-up relative to the texture's own height, not the scene's).windowToRender/renderToWindowonly apply that Y-flip -GameScene.swift's callers already hand themevent.location(in: self)/touch.location(in: self)(scene-space, via SpriteKit's ownNSEvent/UITouchextensions, already accounting forscaleMode/letterboxing), so no furtherSKView.convert(_:to:)is needed; running an already-scene-space point back throughconvert(_:to:)double-applies the transform (a real bug fixed here). Why it looked fine on macOS but not iOS:NSView's coordinate system is already bottom-left/Y-up like SpriteKit's own, so the redundant re-conversion there was a near no-op;UIView's is top-left/Y-down, so the same redundant call introduced a real Y-flip-plus-offset error, which is why touch input on iOS looked "inverted"/grabbed bricks far from the actual touch point while mouse on macOS looked fine. setTextureAlpha/setTextureColorare stored in per-handle side tables and applied when a node is actually created indrawTexture, since SpriteKit applies alpha/color at the node level (SKSpriteNode.alpha/.color+.colorBlendFactor), not the texture level like SDL's*AlphaMod/*ColorMod.- Concurrency:
SpriteKitRendereris declared@MainActor, conforming toGameRenderervia@preconcurrency(the protocol itself stays non-isolated, sinceSDL3Renderer/SDL2Rendererneed to call e.g.destroyTexturefrom adeinit, which can't be main-actor-isolated) - every actual call site is already on the main actor (GameScene.swift'supdate(_:), all ofGameRender.swift), so this is a real fix, not a suppression.
Audio.swift's SoundBoard/MusicPlayer mirror ports/SDL3's SDL3_mixer-backed classes of the
same name verbatim - same SoundID/filename tables, same 5 randomized level-music playlists +
fade-out stings - just built on AVAudioPlayer instead of MIX_* calls:
SoundBoard.play(id:)creates a freshAVAudioPlayerper call (anActiveSoundEffectwrapper keeps it alive viaAVAudioPlayerDelegateuntil playback finishes, then self-removes), so overlapping playback of the same sound (e.g. rapid water drips) works, mirroring SDL_mixer's auto-pick-a-free-channel behavior instead of one reused player per sound.MusicPlayer.update()polls.isPlayingeach frame (GameScene.swift'supdate(_:)already called this every frame before this pass, when it was a no-op) - same poll-based design asports/SDL3's!MIX_TrackPlaying(track)check.
Almost every asset under audio/ is Ogg Vorbis, a format AVAudioPlayer can't decode -
confirmed empirically that macOS's built-in afconvert tool can decode .ogg directly when run
unsandboxed (not an Apple-documented supported input format, but the codec component is present),
so audio is transcoded to Core Audio Format (.caf) instead of shipping a third-party Ogg decoder:
Scripts/transcode-audio.shis the actual transcoding logic (afconvert -f caff -d LEI16@44100, skip-if-newer for fast incremental runs, tolerant of individual file failures), shared between both consumers below.Junkbot.xcodeproj: a Run Script build phase on bothJunkbot-macOSandJunkbot-tvOS, writing into$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/TranscodedAudio/at every build. NamedTranscodedAudio(notAudio) deliberately - macOS's case-insensitive-by-default filesystem means a folder namedAudiowould collide with (and get corrupted by) the originalaudiofolder reference if one existed at the same path; the rawaudiofolder reference was removed from Copy Bundle Resources entirely once this landed, since only the transcoded.caffiles are actually read at runtime.JunkbotMobile.swiftpm: originally used aPlugins/TranscodeAudioPluginSwiftPM build-tool plugin (prebuildCommand) to invoke the same script at build time - removed, since SwiftPM always runs plugins sandboxed with no opt-out, andafconvertcannot decode Ogg Vorbis from inside that sandbox (confirmed: the identicalafconvertinvocation that works fine in the Xcode Run Script phases above - unsandboxed - failed with "Couldn't open input file" when run from inside the plugin's subprocess; every.ogg-sourced sound was silently missing, only the handful of already-.wavones transcoded successfully). Instead,Scripts/transcode-audio.shis run manually once (wheneveraudio/changes) directly intoSources/JunkbotMobile/TranscodedAudio/, which is checked in as real files (not a symlink, unlikeimages/font/levels) and declared as a plain.copy(...)resource inPackage.swift- see that file's doc comment for the exact invocation.
GamepadInput.swift is the Darwin equivalent of ports/SDL3's Input.swift (Phase 7), built on
GameController.framework instead of raw SDL calls:
GCController/GCExtendedGamepadfor full gamepads; falls back toGCMicroGamepad(dpad + one button) whenextendedGamepadisnil- the profile tvOS's Siri Remote actually exposes. This is what makes tvOS playable at all, not just a nice-to-have:GameScene.swift's touch handlers never fire on tvOS (no touchscreen), so before this pass tvOS had no working input path whatsoever despite building successfully - a worse gap than this file previously documented.GCKeyboard.coalesced?.keyboardInputcovers physical keyboards uniformly across macOS/iOS/ tvOS via the same framework, so - unlike the SDL ports - there's no separateNSEvent/UIKeyCommandpath to maintain.- The actual menu-focus navigation logic (
moveFocus/directionPressed/activatePressed/etc.) lives in the sharedMenuFocus.swift(see above) - this file only provides the raw stick/button/key event handling plus the two neutral wrappersMenuFocus.swiftcalls into (hideOSCursor/warpCursor:NSCursor.hide()/.unhide()on macOS, no-op on iOS/tvOS, since there's no meaningful OS cursor there - the shared virtual-cursor drawing inGameRender.swifttakes over automatically oncelastPointingInput/virtualCursorVisibleare driven correctly, with zero new drawing code needed). - A real Swift 6 strict-concurrency wrinkle worth knowing about:
GameControllerisn't yet Sendable-audited, so capturing aGCControlleracross aNotificationCenterclosure boundary (hotplug connect/disconnect) was flagged as a potential data race by the compiler even thoughqueue: .mainguarantees it can't actually race. Neither wrapping the callback inTask { @MainActor in }norMainActor.assumeIsolatedsatisfied the checker (both still treated the non-SendableGCController/Notificationpayload as unsafely "sent" across the closure boundary) - the fix was@preconcurrency import GameController, which is exactly what that annotation is for: telling the compiler to trust an as-yet-unaudited system framework's types the way pre-Swift-6 code did, rather than fighting isolation-region diagnostics over a case the framework's own author hasn't resolved yet.
Not verified on a real gamepad/keyboard in this sandbox (no hardware controller attached) -
verified only via a clean build + smoke-test run (Junkbot-macOS.app launches, stays alive, no
crash) and careful API review against Apple's documented GameController/AVFoundation
behavior.
JunkbotMobile.swiftpm is an "App Playground" - a plain SwiftPM package with a special
Package.swift (import AppleProductTypes, a .iOSApplication product instead of a regular
.executable/.library) that Xcode 15+ and the Swift Playgrounds app both know how to open and
run as a full iOS app, no separate Xcode project needed. Structure:
Package.swift- the.iOSApplicationproduct (landscape-only viasupportedInterfaceOrientations: [.landscapeLeft, .landscapeRight], matching the oldJunkbot-iOStarget's build setting), aJunkbotCoredependency that's a localpath:on macOS or a GitHuburl:everywhere else (see "What's shared vs. Darwin-only" above), andresources:pointing at the symlinked asset directories.Sources/JunkbotMobile/JunkbotMobileApp.swift- the SwiftUI@main App/Sceneentry point (App Playgrounds boot through SwiftUI, not aUIApplicationDelegate), wrapping the sharedGameViewControllerin aUIViewControllerRepresentable.- Everything else in
Sources/JunkbotMobile/is a symlink into eitherSources/JunkbotDarwin/or../SDL3/Sources/JunkbotSDL3/(see "What's shared vs. Darwin-only" above) plus the four symlinked asset directories.
Build-verified via xcodebuild on the command line (previously assumed not to be possible -
corrected here): AppleProductTypes is a module Xcode's own toolchain injects specifically when
opening a .swiftpm App Playground, so it doesn't exist for plain command-line swift build/
swift package describe (confirmed: both fail with "no such module 'AppleProductTypes'"), and
xcodebuild -project JunkbotMobile.swiftpm doesn't recognize the bundle as a project either -
but xcodebuild does auto-detect a .swiftpm App Playground from the current directory the
same way it auto-detects a plain Package.swift, with no -project/-workspace flag at all:
cd ports/Darwin/JunkbotMobile.swiftpm
xcodebuild build -scheme JunkbotMobile -destination "generic/platform=iOS Simulator" \
-skipPackagePluginValidationConfirmed working end-to-end (** BUILD SUCCEEDED **, real JunkbotMobile.app produced under
Debug-iphonesimulator/) - this is what CI now uses (see root .github/workflows/swift.yml's
darwin-ios job).
- tvOS is structurally sound and confirmed buildable, but not build-verified end-to-end in
this sandbox: the tvOS SDK is installed (
xcodebuild -showsdkslistsappletvos26.5), but no destination is eligible without the separate tvOS platform/simulator runtime, whichxcodebuild -downloadPlatform tvOSconfirms is a real, working ~3.76GB download (kicked off successfully here, just not completed - too large/slow for this sandbox's time budget). CI (darwin-tvosin.github/workflows/swift.yml) runs that download step before building, since GitHub's runner image can't be assumed to have every platform pre-installed either. The macOS target was fully built (xcodebuild build, Debug) and run (Junkbot-macOS.applaunched, stayed alive with no crash, correctly saw its bundledimages/font/levelsfolders and transcodedTranscodedAudio/underContents/Resources/) - this remains the one platform buildable/runnable without any extra platform download in this sandbox. iOS is now fully build-verified here too (see "The iOS Playground" above) - a previously-noted concern about the (now-removed)swift-lingodependency'sLingoASTtarget failing to compile for the iOS Simulator SDK ("concurrency is only available in iOS 13.0.0 or newer") turned out not to reproduce against the currentJunkbotMobile.swiftpm(platforms: [.iOS("26.0")], well above whatever old deployment target the original iOS Xcode target used when that issue was last seen) even beforeJunkbotCoredropped the dependency entirely - the build now succeeds cleanly with no workaround needed. - Gamepad/keyboard input and audio are implemented but not verified on real hardware in this sandbox (no controller/keyboard peripheral or audio output to test against) - see "Audio" and "Gamepad + keyboard input" above for what was verified instead (clean builds, smoke-test runs, careful API review).
An earlier pass tried to bring SDL3 to Apple platforms (see git history for the original
"Known, unresolved gap: SDL3 on Apple platforms" section this replaced) and found real blockers:
Homebrew's SDL3 is macOS-host-only, SDL3 has no official SwiftPM package, and neither
SDL3_image nor SDL3_mixer ship a ready Apple XCFramework anywhere - KevinVitale/SwiftSDL
vendors an SDL3.xcframework but doesn't expose an importable C module for it as a public
product. None of that mattered by the time this project (SDL3/SDL2 desktop, Web/WASM, this
Darwin port) already had a GameRenderer protocol seam cleanly separating rendering from
simulation - reimplementing that one seam against SpriteKit (which ships with every Apple SDK,
no vendoring needed) was far less work than solving the SDL3_image/SDL3_mixer XCFramework gap,
and this port never needs SDL's audio/windowing/gamepad pieces anyway (those stay platform-native
here: AppKit/UIKit windowing, and audio/gamepad are open follow-ups either way).
open Junkbot.xcodeproj # macOS + tvOS
open JunkbotMobile.swiftpm # iOS (Xcode or Swift Playgrounds)
Xcode will resolve the local JunkbotCore package dependency automatically in both cases. From
the command line, both Junkbot.xcodeproj and JunkbotMobile.swiftpm (see "The iOS Playground"
above for why the latter works despite being an App Playground, not an ordinary Xcode project)
can be built with xcodebuild ... -skipPackagePluginValidation - JunkbotCore no longer depends
on any build-tool plugin itself (the Lingo-to-Swift transpiler it used to use was removed), but
the flag is still harmless to pass and matches the CI invocations in .github/workflows/swift.yml.