PlayDate
Swift bindings to the Playdate C API.
The Playdate C API is delivered as a PlaydateAPI* struct of function pointers that the firmware hands to your game at launch. This package wraps that surface in idiomatic Swift: namespaced APIs, wrapper types with ownership semantics, closures instead of function-pointer/userdata pairs, OptionSets and enums instead of raw constants, and typed throws for fallible calls.
All ten C subsystems are covered:
| Namespace | Wraps | Highlights |
|---|---|---|
System |
playdate->system |
input, time, menu items, logging |
Display |
playdate->display |
refresh rate, scale, mosaic, flip |
Graphics |
playdate->graphics |
drawing, Bitmap, Font, TileMap, video |
Sprite |
playdate->sprite |
display list, collisions, custom draw |
Sound |
playdate->sound |
players, synths, sequences, effects |
File |
playdate->file |
Handle, directory operations |
JSON |
playdate->json |
Value tree decode/encode |
Lua |
playdate->lua |
C functions, classes, stack access |
Scoreboards |
playdate->scoreboards |
online leaderboards |
Network |
playdate->network |
wifi, HTTPConnection, TCPConnection |
Requirements
- The Playdate SDK (3.1.1 or later). The SDK is not vendored into this repository.
- Swift 6.4 tools or later.
One-time SDK setup
The CPlaydate target resolves pd_api.h through a playdate pkg-config module, so the package works as a normal SwiftPM dependency without unsafe build flags. Generate the pkg-config file once per machine:
Scripts/install-pkgconfig.sh
The script locates the SDK via PLAYDATE_SDK_PATH (default ~/Developer/PlaydateSDK) and writes playdate.pc into a directory SwiftPM searches by default (/usr/local/lib/pkgconfig). Pass a custom destination as an argument if you prefer another location on your PKG_CONFIG_PATH.
If Xcode had the package open before you ran the script, make it re-read the manifest (File ▸ Packages ▸ Reset Package Caches) — Xcode caches package resolution and won't notice the new .pc file on its own.
Adding the dependency
// Package.swift of your game
dependencies: [
.package(url: "https://github.com/<you>/play-date.git", from: "0.1.0"),
// …or, while developing locally:
// .package(path: "../play-date"),
],
targets: [
.target(
name: "MyGame",
dependencies: [.product(name: "PlayDate", package: "play-date")]
),
]
Getting started
A Playdate game has a single C entry point, eventHandler. Export it with @_cdecl, initialize the binding on the first event, and install an update callback:
import CPlaydate
import PlayDate
@_cdecl("eventHandler")
func eventHandler(
pointer: UnsafeMutableRawPointer,
event: PDSystemEvent,
argument: UInt32
) -> Int32 {
switch SystemEvent(event: event, argument: argument) {
case .initialize:
Playdate.initialize(with: pointer) // must happen before anything else
Game.shared.start()
case .pause:
Game.shared.pause()
default:
break
}
return 0
}
final class Game {
nonisolated(unsafe) static let shared = Game()
var player = Sprite()
func start() {
Display.setRefreshRate(50)
System.setUpdateCallback {
self.update()
return true // true = redraw the display this frame
}
}
func update() {
let (_, pushed, _) = System.buttonState
if pushed.contains(.a) {
System.log("A pressed at \(System.currentTimeMilliseconds)ms")
}
Sprite.updateAndDrawAll()
System.drawFPS()
}
}
Playdate.initialize(with:) stores the API pointer once; every wrapper in the module uses it from then on. Calling any wrapper before initialize is a programmer error and will crash.
Tour of the API
System: input, time, menu
// Buttons are an OptionSet: current (held), pushed and released this frame.
let (current, pushed, released) = System.buttonState
if current.contains([.b, .down]) { /* charge shot */ }
// Crank.
if !System.isCrankDocked {
aim(degrees: System.crankAngle)
spin(by: System.crankChange)
}
// Accelerometer is a peripheral you enable first.
System.setPeripheralsEnabled(.accelerometer)
let (x, y, z) = System.accelerometer
// System menu items take closures; the binding keeps them alive until removed.
System.addCheckmarkMenuItem(title: "music", isChecked: true) { item in
Audio.musicEnabled = item.isChecked
}
System.addOptionsMenuItem(title: "mode", options: ["easy", "hard"]) { item in
Game.shared.difficulty = item.value
}
// Logging goes to the simulator console or device serial.
System.log("spawned \(count) enemies")
System.error("unrecoverable") // stops execution
Graphics: drawing, bitmaps, fonts
Fallible loads (Bitmap(path:), Font(path:), …) throw PlaydateError,
which carries the message produced by the OS:
let font = try Graphics.Font(path: "fonts/Asheville-Sans-14-Bold.pft")
Graphics.setFont(font)
Graphics.clear(color: .white)
Graphics.fillRect(x: 0, y: 0, width: 400, height: 32, color: .black)
Graphics.drawText("Hëllo, Playdate", x: 8, y: 8)
// Colors are solid or 8×8 patterns.
let checker = Graphics.Pattern(rows: (0xAA, 0x55, 0xAA, 0x55,
0xAA, 0x55, 0xAA, 0x55))
Graphics.fillEllipse(x: 100, y: 100, width: 64, height: 64,
color: .pattern(checker))
// Bitmaps draw themselves; draw into one by pushing it as the context.
let logo = try Graphics.Bitmap(path: "images/logo")
logo.draw(x: 168, y: 88)
let canvas = Graphics.Bitmap(width: 64, height: 64)
Graphics.pushContext(canvas)
Graphics.drawLine(x1: 0, y1: 0, x2: 63, y2: 63, width: 2, color: .black)
Graphics.popContext()
Sprites and collisions
let ball = Sprite()
ball.setImage(try Graphics.Bitmap(path: "images/ball"))
ball.moveTo(x: 200, y: 120)
ball.collideRect = Rect(x: 0, y: 0, width: 16, height: 16)
ball.setCollisionResponseFunction { _, _ in .bounce }
ball.add() // adds to the display list; the binding keeps it alive while added
// In the update callback:
let (actual, collisions) = ball.moveWithCollisions(goalX: goalX, goalY: goalY)
for collision in collisions where collision.other.tag == Tags.brick {
collision.other.remove()
}
Sprite callbacks (setUpdateFunction, setDrawFunction, setCollisionResponseFunction) receive the Swift wrapper back. The C-level sprite userdata slot is reserved by the binding for that recovery — use the userdata property on Sprite for your own per-sprite storage instead.
Sound
// Stream music from disk.
let music = try Sound.FilePlayer(path: "audio/theme")
music.play(repeat: 0) // 0 = loop forever
// Play short effects from memory.
let blip = try Sound.SamplePlayer(path: "audio/blip")
blip.play()
// Synthesis.
let synth = Sound.Synth(waveform: .square)
synth.setAttackTime(0.01)
synth.setReleaseTime(0.2)
synth.playMIDINote(Sound.noteC4, velocity: 0.8, length: 0.5)
// Channels mix sources and effects.
let channel = Sound.Channel()
channel.add()
channel.addSource(synth)
let filter = Sound.TwoPoleFilter(kind: .lowPass)
filter.setFrequency(800)
channel.addEffect(filter)
// Anything that takes a modulator accepts any SignalValue (LFO, Envelope, …).
let wobble = Sound.LFO(shape: .sine)
wobble.setRate(2)
synth.frequencyModulator = wobble
Files and JSON
// Paths resolve against the game's Data directory and pdx per the open mode.
let save = try File.Handle(path: "save.json", mode: .write)
try save.write(JSON.encode(.table([
"level": .int(3),
"name": .string("Röck"),
])))
try save.close()
let loaded = try JSON.decodeFile(at: "save.json")
if case .table(let entries) = loaded, case .int(let level)? = entries["level"] {
Game.shared.level = level
}
try File.listFiles(at: "replays") { name in
System.log("found \(name)")
}
Network
Network access requires user permission per server:
let reply = Network.HTTPConnection.requestAccess(
server: "example.com", purpose: "Fetching daily puzzles") { allowed in
guard allowed else { return }
Puzzles.fetch()
}
func fetch() {
guard let connection = Network.HTTPConnection(server: "example.com") else { return }
connection.setRequestCompleteCallback { connection in
let body = try? connection.read(length: connection.bytesAvailable)
// … keep `connection` referenced somewhere until this fires …
}
try? connection.get(path: "/daily.json")
}
Lua interop
Lua callbacks are C function pointers with no context, so they must be
@convention(c) functions rather than capturing closures:
let double: Lua.CFunction = { _ in
Lua.push(Lua.intArgument(at: 1) * 2)
return 1 // number of return values pushed
}
try Lua.addFunction(double, name: "mylib.double")
Conventions
- Namespaces. The subsystem namespaces (
System,Graphics,Sound, …) live at the top level of the module; only the raw C API bootstrap stays underPlaydate(Playdate.initialize(with:),Playdate.api). On a name collision with another module, qualify with the module name:PlayDate.System. - Errors. Fallible operations use typed throws —
throws(PlaydateError)generally,throws(Network.NetError)for network I/O — socatchgives you a concrete type, and noany Errorexistentials are needed. - Ownership. A wrapper that creates a C object frees it on
deinit; keep the wrapper referenced for as long as you use it. Wrappers vending OS-owned objects (aBitmapfrom aBitmapTable, a track from aSequence, …) don't free them — keep the owner alive instead, as documented on each API. Resources a C object keeps referencing (a sprite's image, a synth's sample, modulators, menu-item option titles) are retained by the wrapper automatically. - Callbacks. Where the C API provides a userdata slot, closures are supported everywhere and delivered back with the right wrapper. A few C callbacks have no userdata (serial messages, headphone changes, scoreboard
completions,
getServerTime); those track one Swift closure at a time, as noted in their documentation. - Threading. The Playdate runtime is single-threaded (audio callbacks excepted); statics in the binding are
nonisolated(unsafe)on that basis. Don't call the API from other threads.
Building for the simulator and device
This package builds as a plain Swift library, which is how you develop and unit-test game logic on the host (swift test works out of the box).
Shipping a .pdx needs the Playdate toolchain on top:
- Simulator builds compile your game as a host dylib placed in the pdx.
- Device builds require Embedded Swift for ARM Cortex-M7 (
-enable-experimental-feature Embedded, triplearmv7em-none-none-eabi).
The wrappers are written within the Embedded Swift subset for exactly this reason: no Foundation, no reflection, no untyped throws. That claim is enforced, not aspirational: Scripts/build-embedded.sh cross-compiles the whole module for armv7em-none-none-eabi with Embedded Swift enabled, and CI runs it on every push. Running it locally needs a swift.org development snapshot toolchain (Xcode's toolchain doesn't ship the bare-metal embedded stdlib) and the Arm GNU toolchain headers:
SWIFT_BIN=~/Library/Developer/Toolchains/swift-DEVELOPMENT-SNAPSHOT-<date>.xctoolchain/usr/bin/swift \
Scripts/build-embedded.sh
See Apple's swift-playdate-examples for a working Makefile/toolchain setup that this library slots into.
Example
Examples/HelloPlaydate is a complete minimal game — bouncing box, crank needle, button handling, a system menu item — that builds into a runnable simulator .pdx:
cd Examples/HelloPlaydate
./build.sh
open -a "$HOME/Developer/PlaydateSDK/bin/Playdate Simulator.app" HelloPlaydate.pdx
Documentation
The API reference is a DocC catalog. Generate it locally with:
swift package generate-documentation --target PlayDate
or browse it with Xcode's documentation viewer (Product ▸ Build Documentation). Pushes to main publish the rendered docs to GitHub Pages via .github/workflows/docs.yml (enable Pages ▸ Source: GitHub Actions in the repository settings once).
Layout
Examples/
HelloPlaydate/ Minimal game buildable into a simulator .pdx
Scripts/
install-pkgconfig.sh One-time setup: points the "playdate" pkg-config
module at your SDK installation
build-embedded.sh Compile-only device check: Embedded Swift for
armv7em-none-none-eabi (run by CI)
consumer-test.sh Builds a scratch package depending on play-date
to prove settings propagate to consumers (CI)
Sources/
CPlaydate/ System library target: module map + umbrella header
importing pd_api.h from the SDK, plus inline shims for
the variadic log/error functions
PlayDate/ The Swift bindings, one file per subsystem
(Sound and Graphics are split across several files),
plus the PlayDate.docc documentation catalog
Tests/
PlayDate/ Host-runnable tests for the pure value types
License
MIT — see LICENSE. The Playdate SDK itself is licensed separately by Panic, Inc. and is not distributed with this package.