Articles
A year building a 2D game renderer out of Core Graphics, one bad benchmark at a time.
David Foreman · August 2026
I finally shipped a game. It is a 100-level arcade space shooter called VYRON, it runs on Mac and iPhone from one codebase, and it uses no game engine. There is no SpriteKit or Unity underneath, and no custom Metal scene graph either. The game content is drawn every frame with Core Graphics calls, the same API you would use to render a pie chart.
A few people in a Reddit thread asked me to write up how that works and why anyone would do it. This is that write-up, put together from the project's own record: the commit history, the profiler reports, and the experiments still archived in the tree. I have tried to be honest about the parts that went wrong, because the measurement from a failed experiment turned out to be worth more to me than most of the things that worked first time.
I wanted a game I could disappear into for twenty minutes. I love complex games, but I don't always want to learn a huge world or commit an evening. The games that used to scratch that itch were arcade games: shmups, the coin-op tradition, games that are pure fun in the first ten seconds and still have something left to master hours later. What mostly fills that slot now is one-mechanic puzzles and ad timers. So I spent a year building the thing I wanted back: a shmup that is arcade-simple to pick up, with the depth of a real game underneath it. It came out at about 90,000 lines of Swift.
The renderer choice fell out of two wants. The first is the art style. VYRON's look is vector-ish: polygons, gradients, glows, no sprite sheets. Everything on screen is geometry with a fill, and Core Graphics is good at that. The second want is the one that ruled out engines and sprites for good: I wanted the game to scale. Because everything is geometry, the game renders at whatever resolution it is given, from an 800×600 window to a 4K display and beyond, and it only gets better for it, as long as the hardware can cope. A sprite is authored at one size and gets softer the further you move from it; a polygon with a gradient fill is exact at every size. So the plan was: a CGContext, a display link, and a draw function that renders the whole frame from scratch, every frame.
The renderer ended up as roughly 7,600 lines shared verbatim between the two platforms, split across about 85 draw functions. The platform layer underneath it is a handful of files: display link, input, tilt, haptics. Everything above that line has no idea whether it is running on a Mac or a phone.
I allocated gradients every frame, for months. Core Graphics gradients are objects you build from colour stops, and I was building them inside draw calls, which means thousands of allocations per second at 60fps. The fix was a nine-line memoiser, and it was the single largest performance win of the whole project. Nothing else came close.
// GameView.swift — gradient cache, memoizes static gradients on first use
private var _gc: [Int: CGGradient] = [:]
func gc(_ id: Int, _ f: () -> CGGradient) -> CGGradient {
if let g = _gc[id] { return g }
let g = f(); _gc[id] = g; return g
}
There are over 160 gradients living in that cache now. The embarrassing part is how long I went without profiling at all, because the game felt fine on my Mac. The lesson I actually learned was not “cache your gradients”, it was “your development machine is lying to you”.
The obvious Metal bridge was 5 to 10 times slower. When iPhone performance
became a problem, the first fix I reached for was getting the frame onto the GPU myself:
render into a CGBitmapContext, hand the bytes to a Metal texture, draw a quad. I built it properly.
It measured 5 to 10 times slower than what I already had. Copying a full-resolution
frame's bytes across every frame swamped whatever the GPU gave back. Those 17 files are
still in the repo in a folder called Archive/v5-metal-bridge-experiment,
because the measurement is the useful part and I did not trust myself to remember the
number without it.
UIKit was spending 70ms moving my finished frame. Profiling on device showed the software-to-GPU handoff of the finished frame costing about 70ms inside UIKit's own compositing path, and no amount of faster drawing was going to claw that back. This is the problem the failed Metal bridge was trying to solve by brute force. The actual solution was to stop copying: draw into memory the GPU can already see.
A race condition that only existed in Release builds, on a physical iPhone. The
particle system runs a Metal compute pass, and the CPU reads results back. In Debug, in
the Simulator, and on the Mac, this worked. On a real iPhone in a Release build,
particles froze and piled up in corners. The compute pass had not finished when the CPU
read the buffer; every other configuration was just slow enough to hide it. The fix was
one waitUntilCompleted call. Finding it took two long, long days, because
my instinct was to distrust my particle logic rather than my synchronisation.
The Mac build is Apple Silicon only, and that is a corner I architected myself into. The App Store build is arm64 only: M1 and newer. Nothing about the renderer required that. It happened because I committed first and tested too late; a year of performance decisions had quietly assumed the machine on my desk, and by the time I looked beyond it the choice had already been made for me. It is the gradient lesson again at architecture scale: my development machine was lying to me, and this time I found out too late to do anything about it.
Apple rejected the Mac build because my canvas could clip. The reviewer ran the game in a window shape I had never tried, and the canvas clipped. Root cause: I had duplicated a height-only scaling transform in nine places across the renderer, and they had drifted. I replaced all nine with one shared fit-to-window transform that mathematically cannot clip at any window shape. The rejection stung and the fix made the code better, which is an annoying combination. While I was in there I found a first-run bug where an unset preference read as 800×600 instead of picking a screen-appropriate size. Nobody had ever hit it because everyone who tested the game had run an earlier build first. Reviewers start from nothing; so do real players.
Drawing into an IOSurface, so the GPU reads the frame instead of receiving it. This is the pipeline that shipped on iPhone. An IOSurface is a block of memory both the CPU and GPU can address. The CGContext wraps that memory and draws into it; a Metal texture is created as a zero-copy view of the same memory; presenting is a single textured quad to a CAMetalLayer. The frame is never copied. Double-buffered, so the CPU draws into one surface while the GPU presents the other.
iPhone frame, shipped pipeline (v5)
CPU GPU
+---------------+ same memory +---------------+
| CGContext | ----------------> | MTLTexture |
| draws into | (IOSurface) | zero-copy |
| IOSurface A | | view of A |
+---------------+ +-------+-------+
^ |
| | one textured quad
~85 draw funcs v
(shared with Mac) CAMetalLayer drawable
next frame: draw into B while A presents (double buffer)
The creation code is unremarkable, which is rather the point. The context is an ordinary CGContext; all 85 draw functions run against it unchanged:
// IOSurfaceCanvas.swift — CGContext wrapping the surface's memory
surface.lock(options: [], seed: nil)
let ctx = CGContext(
data: surface.baseAddress,
width: pw, height: ph,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow, // 64-byte aligned for Metal
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
| CGBitmapInfo.byteOrder32Little.rawValue
)
surface.unlock(options: [], seed: nil)
// Metal texture as a zero-copy view of the same surface
let tex = device.makeTexture(descriptor: texDesc, iosurface: surface, plane: 0)
The Mac never needed any of this. On macOS the game draws its CGContext straight into the NSView backing store with a CVDisplayLink driving redraws, the way AppKit has worked for twenty years. I want to be precise about that because my own Reddit posts blurred it: the IOSurface pipeline exists because UIKit's frame delivery was the bottleneck, and AppKit's simply was not. Same renderer, two delivery mechanisms, and the boring old one is still in service on the platform where it was never a problem.
Six levels of rendering detail, managed in the background. The renderer runs at one of six detail levels, from basic up to cinematic. A rolling average of frame time decides which: struggling drops it one, coping well for long enough recovers it, and a cooldown stops it oscillating between the two. Resolution sets the ceiling, so a big display is allowed the top levels and a small window is never asked to pay for them. Between this and the vector drawing, the same build runs from an 800×600 window to fullscreen 4K and simply spends whatever the hardware can afford.
A thermal governor instead of a fixed frame rate. Long sessions on a phone are a heat problem before they are a performance problem. Sustained load steps the frame rate from 60 to 30 and back, so a forty-minute session does not cook the phone or the battery. Related, from on-device testing: a render scale of 1.5 turned out to be the 60fps sweet spot on modern iPhones, and 2.0 fell off a cliff. Not “got slower”. A cliff.
Caching everything that survives a frame. The gradient cache came first, then the same pattern spread: fonts and colours are cached, text the HUD redraws every frame is cached as rendered images, enemy polygon meshes are cached. The renderer redraws the world from scratch each frame, but almost nothing inside a frame is constructed twice. When I later added a Metal post-process pass for a holographic foil effect on achievement cards, it measured 2.4ms a frame on an iPhone 17 Pro, and the budget existed because of all that caching.
A hand-rolled Core Graphics renderer was the right call for this specific game, and it would be the wrong call for almost anything else. It worked because the drawing model matched the art style: the game is polygons and gradients, and that is what Core Graphics does well. For a sprite-based game, or anything 3D, I would use an engine and not think twice about it.
What I would keep regardless of renderer: profile on the weakest real device early, keep failed experiments in the repo with their measurements, and be suspicious of anything you have pasted nine times.
The part I got most wrong had nothing to do with rendering. I built the entire game before telling a single person it existed, and started the marketing the day it went live, which is about a year too late. But that is a different write-up.
VYRON is $0.99 on the App Store, one purchase for Mac and iPhone. Questions about any of this: VyronAdmin@proton.me.