Swift bindings to the Playdate C API
Wraps all ten subsystems of the Playdate SDK 3.1.1 C API (system, display, graphics, sprites, sound, file, JSON, Lua, scoreboards, network) in idiomatic Swift: namespaced APIs, wrapper types with ownership semantics, closures for callbacks, and typed throws. Written within the Embedded Swift subset so the same code can target the device. The SDK headers are resolved through a "playdate" pkg-config module (Scripts/install-pkgconfig.sh), so the package works as a normal SwiftPM dependency without unsafe build flags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
# PlayDate
|
||||
|
||||
Swift bindings to the [Playdate](https://play.date) 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,
|
||||
`OptionSet`s and `enum`s instead of raw constants, and typed `throws` for
|
||||
fallible calls.
|
||||
|
||||
All ten C subsystems are covered:
|
||||
|
||||
| Namespace | Wraps | Highlights |
|
||||
|---|---|---|
|
||||
| `Playdate.System` | `playdate->system` | input, time, menu items, logging |
|
||||
| `Playdate.Display` | `playdate->display` | refresh rate, scale, mosaic, flip |
|
||||
| `Playdate.Graphics` | `playdate->graphics` | drawing, `Bitmap`, `Font`, `TileMap`, video |
|
||||
| `Playdate.Sprite` | `playdate->sprite` | display list, collisions, custom draw |
|
||||
| `Playdate.Sound` | `playdate->sound` | players, synths, sequences, effects |
|
||||
| `Playdate.File` | `playdate->file` | `Handle`, directory operations |
|
||||
| `Playdate.JSON` | `playdate->json` | `Value` tree decode/encode |
|
||||
| `Playdate.Lua` | `playdate->lua` | C functions, classes, stack access |
|
||||
| `Playdate.Scoreboards` | `playdate->scoreboards` | online leaderboards |
|
||||
| `Playdate.Network` | `playdate->network` | wifi, `HTTPConnection`, `TCPConnection` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- The [Playdate SDK](https://play.date/dev/) (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:
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```swift
|
||||
// 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:
|
||||
|
||||
```swift
|
||||
import CPlaydate
|
||||
import PlayDate
|
||||
|
||||
@_cdecl("eventHandler")
|
||||
func eventHandler(pointer: UnsafeMutableRawPointer,
|
||||
event: PDSystemEvent,
|
||||
argument: UInt32) -> Int32 {
|
||||
switch Playdate.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 = Playdate.Sprite()
|
||||
|
||||
func start() {
|
||||
Playdate.Display.setRefreshRate(50)
|
||||
|
||||
Playdate.System.setUpdateCallback {
|
||||
self.update()
|
||||
return true // true = redraw the display this frame
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
let (_, pushed, _) = Playdate.System.buttonState
|
||||
if pushed.contains(.a) {
|
||||
Playdate.System.log("A pressed at \(Playdate.System.currentTimeMilliseconds)ms")
|
||||
}
|
||||
|
||||
Playdate.Sprite.updateAndDrawAll()
|
||||
Playdate.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
|
||||
|
||||
```swift
|
||||
// Buttons are an OptionSet: current (held), pushed and released this frame.
|
||||
let (current, pushed, released) = Playdate.System.buttonState
|
||||
if current.contains([.b, .down]) { /* charge shot */ }
|
||||
|
||||
// Crank.
|
||||
if !Playdate.System.isCrankDocked {
|
||||
aim(degrees: Playdate.System.crankAngle)
|
||||
spin(by: Playdate.System.crankChange)
|
||||
}
|
||||
|
||||
// Accelerometer is a peripheral you enable first.
|
||||
Playdate.System.setPeripheralsEnabled(.accelerometer)
|
||||
let (x, y, z) = Playdate.System.accelerometer
|
||||
|
||||
// System menu items take closures; the binding keeps them alive until removed.
|
||||
Playdate.System.addCheckmarkMenuItem(title: "music", isChecked: true) { item in
|
||||
Audio.musicEnabled = item.isChecked
|
||||
}
|
||||
Playdate.System.addOptionsMenuItem(title: "mode", options: ["easy", "hard"]) { item in
|
||||
Game.shared.difficulty = item.value
|
||||
}
|
||||
|
||||
// Logging goes to the simulator console or device serial.
|
||||
Playdate.System.log("spawned \(count) enemies")
|
||||
Playdate.System.error("unrecoverable") // stops execution
|
||||
```
|
||||
|
||||
### Graphics: drawing, bitmaps, fonts
|
||||
|
||||
Fallible loads (`Bitmap(path:)`, `Font(path:)`, …) throw `Playdate.Error`,
|
||||
which carries the message produced by the OS:
|
||||
|
||||
```swift
|
||||
let font = try Playdate.Graphics.Font(path: "fonts/Asheville-Sans-14-Bold.pft")
|
||||
Playdate.Graphics.setFont(font)
|
||||
|
||||
Playdate.Graphics.clear(color: .white)
|
||||
Playdate.Graphics.fillRect(x: 0, y: 0, width: 400, height: 32, color: .black)
|
||||
Playdate.Graphics.drawText("Hëllo, Playdate", x: 8, y: 8)
|
||||
|
||||
// Colors are solid or 8×8 patterns.
|
||||
let checker = Playdate.Graphics.Pattern(rows: (0xAA, 0x55, 0xAA, 0x55,
|
||||
0xAA, 0x55, 0xAA, 0x55))
|
||||
Playdate.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 Playdate.Graphics.Bitmap(path: "images/logo")
|
||||
logo.draw(x: 168, y: 88)
|
||||
|
||||
let canvas = Playdate.Graphics.Bitmap(width: 64, height: 64)
|
||||
Playdate.Graphics.pushContext(canvas)
|
||||
Playdate.Graphics.drawLine(x1: 0, y1: 0, x2: 63, y2: 63, width: 2, color: .black)
|
||||
Playdate.Graphics.popContext()
|
||||
```
|
||||
|
||||
### Sprites and collisions
|
||||
|
||||
```swift
|
||||
let ball = Playdate.Sprite()
|
||||
ball.setImage(try Playdate.Graphics.Bitmap(path: "images/ball"))
|
||||
ball.moveTo(x: 200, y: 120)
|
||||
ball.collideRect = Playdate.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
|
||||
|
||||
```swift
|
||||
// Stream music from disk.
|
||||
let music = try Playdate.Sound.FilePlayer(path: "audio/theme")
|
||||
music.play(repeat: 0) // 0 = loop forever
|
||||
|
||||
// Play short effects from memory.
|
||||
let blip = try Playdate.Sound.SamplePlayer(path: "audio/blip")
|
||||
blip.play()
|
||||
|
||||
// Synthesis.
|
||||
let synth = Playdate.Sound.Synth(waveform: .square)
|
||||
synth.setAttackTime(0.01)
|
||||
synth.setReleaseTime(0.2)
|
||||
synth.playMIDINote(Playdate.Sound.noteC4, velocity: 0.8, length: 0.5)
|
||||
|
||||
// Channels mix sources and effects.
|
||||
let channel = Playdate.Sound.Channel()
|
||||
channel.add()
|
||||
channel.addSource(synth)
|
||||
let filter = Playdate.Sound.TwoPoleFilter(kind: .lowPass)
|
||||
filter.setFrequency(800)
|
||||
channel.addEffect(filter)
|
||||
|
||||
// Anything that takes a modulator accepts any SignalValue (LFO, Envelope, …).
|
||||
let wobble = Playdate.Sound.LFO(shape: .sine)
|
||||
wobble.setRate(2)
|
||||
synth.frequencyModulator = wobble
|
||||
```
|
||||
|
||||
### Files and JSON
|
||||
|
||||
```swift
|
||||
// Paths resolve against the game's Data directory and pdx per the open mode.
|
||||
let save = try Playdate.File.Handle(path: "save.json", mode: .write)
|
||||
try save.write(Playdate.JSON.encode(.table([
|
||||
"level": .int(3),
|
||||
"name": .string("Röck"),
|
||||
])))
|
||||
try save.close()
|
||||
|
||||
let loaded = try Playdate.JSON.decodeFile(at: "save.json")
|
||||
if case .table(let entries) = loaded, case .int(let level)? = entries["level"] {
|
||||
Game.shared.level = level
|
||||
}
|
||||
|
||||
try Playdate.File.listFiles(at: "replays") { name in
|
||||
Playdate.System.log("found \(name)")
|
||||
}
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
Network access requires user permission per server:
|
||||
|
||||
```swift
|
||||
let reply = Playdate.Network.HTTPConnection.requestAccess(
|
||||
server: "example.com", purpose: "Fetching daily puzzles") { allowed in
|
||||
guard allowed else { return }
|
||||
Puzzles.fetch()
|
||||
}
|
||||
|
||||
func fetch() {
|
||||
guard let connection = Playdate.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:
|
||||
|
||||
```swift
|
||||
let double: Playdate.Lua.CFunction = { _ in
|
||||
Playdate.Lua.push(Playdate.Lua.intArgument(at: 1) * 2)
|
||||
return 1 // number of return values pushed
|
||||
}
|
||||
try Playdate.Lua.addFunction(double, name: "mylib.double")
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Namespaces.** Everything lives under `Playdate`. Games that find that
|
||||
verbose can alias: `typealias Graphics = Playdate.Graphics`.
|
||||
- **Errors.** Fallible operations use typed throws — `throws(Playdate.Error)`
|
||||
generally, `throws(Playdate.Network.NetError)` for network I/O — so `catch`
|
||||
gives you a concrete type, and no `any Error` existentials 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 (a `Bitmap` from a `BitmapTable`, a track from a
|
||||
`Sequence`, …) 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`, triple `armv7em-none-none-eabi`).
|
||||
|
||||
The wrappers are written within the Embedded Swift subset for exactly this
|
||||
reason: no Foundation, no reflection, no untyped throws. See Apple's
|
||||
[swift-playdate-examples](https://github.com/apple/swift-playdate-examples)
|
||||
for a working Makefile/toolchain setup that this library slots into.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Scripts/
|
||||
install-pkgconfig.sh One-time setup: points the "playdate" pkg-config
|
||||
module at your SDK installation
|
||||
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)
|
||||
Tests/
|
||||
PlayDate/ Host-runnable tests for the pure value types
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE). The Playdate SDK itself is licensed separately
|
||||
by Panic, Inc. and is not distributed with this package.
|
||||
Reference in New Issue
Block a user