javierandClaude Fable 5 61fb06e45e Fix CallbackSource registrations outliving their sources
Callback-based sound sources are kept alive in a static registry while the
C side may still invoke their trampoline, but nothing ever removed them:
every callback source leaked its wrapper and closure permanently, and a
source retained only by a transient Channel.default wrapper relied on that
leak for safety. The registry is now the single source of truth and is
purged on Sound.removeSource, Channel.removeSource, and when an owned
channel is freed (its sources can no longer be pulled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 07:09:30 +02:00
2026-07-24 10:33:45 +02:00

PlayDate

CI

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 under Playdate (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 — 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. 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.

2026-07-25 23:02:18 +00:00
Languages
Swift 97%
Shell 1.9%
Makefile 0.8%
C 0.3%