A macOS desktop companion: a retro 8-bit sprite that lives in a transparent strip across the bottom of your screen, wanders around on its own, reacts to your cursor, and runs small macOS actions when you click it.
Built with AppKit + SwiftUI. Requires macOS 13 Ventura or later, and Xcode 15 or later to build.
There is no .xcodeproj in this repository — only sources. Creating the target
yourself takes about two minutes and means the project settings are ones you chose.
- Xcode → File ▸ New ▸ Project…
- Choose macOS ▸ App, then Next.
- Product Name:
DesktopSprite· Interface: SwiftUI · Language: Swift. Leave "Use Core Data" and "Include Tests" unchecked. - Save it inside this repository folder (the same directory as this README).
Delete the two files Xcode generated — this project supplies its own versions:
ContentView.swiftDesktopSpriteApp.swift(whatever Xcode named your@mainfile)
Choose Move to Trash, not "Remove Reference".
Drag the Sources/DesktopSprite folder from Finder into the Xcode project navigator.
In the dialog that appears:
- ☑ Copy items if needed — leave unchecked so the files stay in Git where they are.
- Select Create groups.
- ☑ Add to target DesktopSprite.
Xcode will try to add Info.plist and DesktopSprite.entitlements as compiled
resources. Remove both from Build Phases ▸ Copy Bundle Resources — they are
referenced by build settings, not copied.
In the target's Build Settings, set:
| Setting | Value |
|---|---|
| macOS Deployment Target | 13.0 |
| Info.plist File | Sources/DesktopSprite/Info.plist |
| Code Signing Entitlements | Sources/DesktopSprite/DesktopSprite.entitlements |
| Generate Info.plist File | No |
If you would rather keep Xcode's generated Info.plist, skip the plist rows above and
instead go to Build Settings, search for LSUIElement, and set
Application is agent (UIElement) to YES. That single key is the only thing in
Info.plist that the app actually depends on.
⌘R. The sprite appears above your Dock and a 🎮 icon appears in the menu bar.
There is no Dock icon and no application menu.
LSUIElementmakes this a background agent, which is what makes it feel like part of the desktop rather than a window you have to manage. Quit from the menu-bar icon. If you remove theMenuBarExtrafromDesktopSpriteApp.swift, you will have no way to quit the app short of Activity Monitor.
App/
DesktopSpriteApp.swift @main. MenuBarExtra + NSApplicationDelegateAdaptor.
AppDelegate.swift Owns the window, view model and actions. Watches the
system for screen and sleep changes.
SpriteWindow.swift The transparent NSPanel and its hosting view.
Model/
SpriteConfiguration.swift Every tunable number in the app.
SpriteState.swift The state machine: states, animations, transition rules.
ViewModel/
SpriteViewModel.swift The tick loop: physics, wander AI, reactions, throttling.
MouseTracker.swift Cursor proximity, with hysteresis.
Rendering/
SpriteProvider.swift Protocol seam between behaviour and artwork.
PlaceholderSprite.swift Built-in pixel art, authored as character grids.
PixelArt.swift Character grid → CGImage.
SpriteSheet.swift PNG sprite sheet → per-state frames.
DesktopSpriteView.swift The SwiftUI view. No logic — it reads and draws.
Actions/
SpriteAction.swift Protocol + registry.
BuiltInActions.swift Sound, notification, open-link, and a logging template.
Support/
ScreenGeometry.swift Screen selection and coordinate conversion.
LoginItemController.swift SMAppService wrapper for "Open at Login".
These are the decisions that are not obvious from the code, and the reasons behind them.
The strip is click-through except where the sprite is. The window spans the full
width of the screen. If it accepted mouse events it would swallow every click on the
desktop and on the bottom edge of every other window. So ignoresMouseEvents is true
by default, and SpriteViewModel flips it off only while the cursor is inside the
sprite's bounding box. This is the single most important thing to preserve if you
refactor the window code.
Cursor position is polled, not monitored. NSEvent.mouseLocation is read once per
tick. A local event monitor would see nothing, because the window ignores mouse events
almost everywhere. A global monitor would work — global mouse monitors do not need
Accessibility permission, only keyboard ones do — but polling is synchronous, always in
step with the physics it feeds, and has no object lifetime to manage.
NSScreen.screens.first, not NSScreen.main. NSScreen.main is the screen with
keyboard focus, so it changes every time you click on a different display. The
companion is docked to the primary screen instead.
Proximity uses two thresholds. The sprite starts reacting at 50 points and stops at 80. With one threshold, a cursor parked on the boundary flickers the sprite between states on every tick.
.interpolation(.none) is required. Without it, a 16×16 sprite scaled to 64×64 on
a Retina display is smeared into a blur. Both the CGImage (shouldInterpolate: false)
and the SwiftUI Image set it.
Position is snapped to the physical pixel grid. Nearest-neighbour scaling alone is
not enough. The view model advances position by speed × delta, which lands the sprite
on fractional point values; at 4× scale that makes some pixel columns render one device
pixel wider than others, with the pattern shifting every frame. The result is a sprite
that shimmers and crawls as it moves. DesktopSpriteView.pixelSnapped(_:) rounds the
centre to whole device pixels using @Environment(\.displayScale), so it stays correct
when the window moves between a Retina and a non-Retina display.
One timer, two rates. A single tick drives physics, cursor sampling and frame
advance. It runs at 60 Hz while anything is happening and drops to 12 Hz once the sprite
has been idle and alone for three seconds, and suspends entirely when the display sleeps.
This app runs all day; TimelineView(.animation) would keep the GPU busy forever.
Everything lives in SpriteConfiguration.swift — speed, gravity, jump height, the
proximity thresholds, the wander timings, the tick rates, the sprite size. Nothing else
in the app hard-codes a number.
The app ships with programmatic pixel art so it looks like something the moment you build it. To replace it with real artwork:
-
Author a PNG laid out as one row per state, one column per frame, in the order
SpriteState.allCasesdeclares — idle, runningRight, runningLeft, surprised, jumping. Frame counts come fromSpriteState.animation.frameCount:Row State Frames 0 idle 2 1 runningRight 4 2 runningLeft 4 3 surprised 2 4 jumping 1 At the default 16×16 frame size that is a 64×80 pixel image. Rows may be shorter than the widest row; trailing cells are never read.
-
Name it
SpriteSheet.pngand add it to the target — either intoAssets.xcassetsor directly as a file resource. Both are checked. -
Build. That is all:
SpriteProviderFactorylooks for the asset at launch and uses it if it is there, falling back to the placeholder art if it is missing or the wrong size. No code changes are needed.To use a different name or frame size, change
spriteSheetAssetName/spriteSheetFrameSizeinSpriteConfiguration.swift.
Artwork should face right; the view mirrors it horizontally for leftward movement.
If you would rather author separate left-facing frames, drop the .scaleEffect in
DesktopSpriteView.swift and give runningLeft its own row.
Conform to SpriteAction and register it in AppDelegate.registerActions():
struct OpenTerminalAction: SpriteAction {
let id = "terminal"
let title = "Open Terminal"
let systemImage = "terminal"
func perform() {
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
}
}Registered actions appear in the menu bar automatically. Set
actionRegistry.defaultActionID to choose which one clicking the sprite fires.
LogAction in BuiltInActions.swift is the minimal template.
Add a case to SpriteState and fill in the four switches — animation,
minimumDuration, impliedFacing, allowsWandering. The tick loop needs no changes.
Add a matching row to your sprite sheet, or a pose to PlaceholderSprite.
The menu bar has an Open at Login toggle, backed by SMAppService (macOS 13+,
no helper target required — it replaced the old SMLoginItemSetEnabled bundle dance).
This cannot be meaningfully tested from Xcode. SMAppService registers the path
the app currently occupies, and running from Xcode that path is inside DerivedData,
which gets wiped on the next clean build. Registration appears to succeed and then
silently stops working. To actually test it: Product ▸ Archive, export the app, move it
to /Applications, and toggle it there.
Registration can also land in .requiresApproval rather than .enabled — macOS puts
it there when the user has previously disabled the item in System Settings. The menu
shows an Approve in System Settings… shortcut when that happens.
Every failure is logged and swallowed. Failing to become a login item is a
disappointment, not a reason for a background app to misbehave at launch. Check
Console.app for the LoginItem category if the toggle does not stick.
The app runs inside the App Sandbox and none of the built-in actions need an exception. Reading the cursor position, floating a window, playing a system sound and posting a local notification are all permitted.
One limit worth knowing: NSWorkspace.open works under the sandbox for registered URL
schemes (https, mailto, and so on), but launching an arbitrary .app by file path
does not. If you add an action that needs to do that, set
com.apple.security.app-sandbox to <false/> in DesktopSprite.entitlements.
Notification permission is requested lazily, the first time a notification action runs — not at launch. A background app that fires a permission prompt the instant it starts is obnoxious, and the prompt makes more sense once the user has actually asked for one.
This prototype has not been compiled — it was written on a machine without Xcode, so the first build is yours. Beyond "it launches", here is what is worth checking:
- No Dock icon, sprite visible above the Dock, and Quit works from the menu bar.
- Click on the empty desktop through the strip. The click must reach the desktop.
If the strip swallows it,
ignoresMouseEventsis not being managed correctly. - Move the cursor slowly toward the sprite. It should react once at roughly 50 points away, and hovering exactly at that boundary should not flicker.
- Click the sprite. It bounces and the default action fires (a "Pop" sound).
- Toggle Dock auto-hide, then change the Dock size. The strip re-anchors each time.
- Switch Spaces, then open a full-screen app. The sprite follows and stays visible.
- Leave it alone for a minute and watch it in Activity Monitor. CPU should drop noticeably as the tick throttles from 60 Hz to 12 Hz. (This is the design intent, not a measured figure — the throttle has never been profiled.)
- Plug in a second display, or change resolution. The sprite stays on the primary screen and stays inside its bounds.
- Watch the sprite closely while it runs. Pixels should stay crisp and uniform.
Shimmering or pixel columns changing width means
pixelSnapped(_:)is not doing its job. - Open at Login — only testable from
/Applications, see above.
- The sprite lives on the primary display only. Multi-display support would mean one window per screen and a policy for which one the sprite occupies.
- The Dock is treated as being at the bottom. With the Dock on the left or right,
visibleFramestill gives a correct bottom edge, so the sprite works — it just does not know the Dock is beside it. .jumpingis a single held pose. A real jump would want separate rise, apex and fall frames; add them as frames 0–2 and give the stateloops: false.- Physics are deliberately minimal: one axis of gravity, no collision with anything but the ground line and the screen edges.
- Nothing persists. Sprite visibility and the chosen default action reset on every launch. Only the login item survives, because macOS stores that itself.
- A click can be dropped in one narrow case. The
ignoresMouseEventsflip happens on the tick, which is 83 ms while dormant. Moving the cursor from outside the 50-point proximity radius onto the sprite and clicking within that window sends the click to the desktop instead. Approaching at any normal speed restores the 60 Hz tick first. - The sprite appears in screenshots and screen shares. Setting
window.sharingType = .noneinSpriteWindowexcludes it from capture while leaving it visible to you. - Reduced motion is not honoured. A perpetually moving object in peripheral vision
is a real accessibility problem. Checking
NSWorkspace.shared.accessibilityDisplayShouldReduceMotionand suppressing the wander — keeping only cursor reactions — would address it. - A global hide/show hotkey would need Accessibility permission. Mouse monitoring does not, but keyboard monitoring does, which changes the app's install story.