skills/playdate-dev/SKILL.md
Playdate game development in Lua with the Playdate SDK. Covers game loop, sprites, graphics, input (crank, buttons, accelerometer), audio, UI, performance, metadata (pdxinfo), and simulator/device workflow. Use when asked to make a Playdate game, implement Playdate-specific mechanics, or apply Playdate design and accessibility guidelines.
npx skillsauth add ckorhonen/claude-skills playdate-devInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build Playdate games in Lua using the official Playdate SDK. The Playdate is a small yellow handheld with a 400×240 1-bit display, a physical crank, A/B buttons, and a D-pad. Understanding its constraints and unique input is essential.
Hardware specs:
playdate.update(), update game state, call playdate.graphics.sprite.update() and playdate.timer.updateTimers() when used).pdxinfo, buildNumber, launcher card and icon sizes).assets/lua-starter into a new project folder.Source/main.lua and Source/pdxinfo in the source root.pdxinfo and extend the update loop.Build with:
pdc Source GameName.pdx # compile to .pdx bundle
# Then open GameName.pdx in the Simulator, or drag to device
-- main.lua
import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"
local gfx <const> = playdate.graphics
-- Game state
local playerX, playerY = 200, 120
function playdate.update()
-- 1. Handle input
if playdate.buttonIsPressed(playdate.kButtonLeft) then
playerX -= 2
elseif playdate.buttonIsPressed(playdate.kButtonRight) then
playerX += 2
end
-- 2. Handle crank
local crankDelta = playdate.getCrankChange() -- degrees since last update
playerY += crankDelta * 0.1 -- map crank to movement
-- 3. Update sprites and timers (required each frame if used)
gfx.sprite.update()
playdate.timer.updateTimers()
-- 4. Draw (if not using sprites)
gfx.clear()
gfx.fillCircleAtPoint(playerX, playerY, 10)
end
-- Check if button is held this frame
playdate.buttonIsPressed(playdate.kButtonA)
playdate.buttonIsPressed(playdate.kButtonB)
playdate.buttonIsPressed(playdate.kButtonUp)
playdate.buttonIsPressed(playdate.kButtonDown)
playdate.buttonIsPressed(playdate.kButtonLeft)
playdate.buttonIsPressed(playdate.kButtonRight)
-- Single-frame press/release events
local pressed, released = playdate.getButtonState()
if pressed & playdate.kButtonA ~= 0 then
-- A was pressed this frame
end
-- Callbacks (simpler for menu-style code)
function playdate.AButtonDown()
-- A pressed
end
function playdate.AButtonUp()
-- A released
end
-- Angle in degrees (0-359.99, clockwise = positive)
local angle = playdate.getCrankPosition()
-- Delta since last frame (positive = clockwise)
local delta = playdate.getCrankChange()
-- Detect if crank is docked (folded away)
if playdate.isCrankDocked() then
-- Show "pull out crank" hint or use button fallback
end
-- Request dock/undock alerts
playdate.setCrankSoundsDisabled(true) -- suppress built-in clicks
-- Must opt-in (saves battery when off)
playdate.startAccelerometer()
-- Read in update()
local x, y, z = playdate.readAccelerometer()
-- x,y,z each in range ~[-1, 1] (1G = 1.0)
-- x: tilt left/right, y: tilt front/back, z: up/down
-- Remember to stop when not needed
playdate.stopAccelerometer()
local gfx <const> = playdate.graphics
-- Screen is black (0) and white (1) only
gfx.setColor(gfx.kColorBlack) -- or kColorWhite, kColorClear, kColorXOR
gfx.setImageDrawMode(gfx.kDrawModeFillBlack) -- for rendering images
-- Immediate drawing (clears each frame)
function playdate.update()
gfx.clear(gfx.kColorWhite) -- clear to white
gfx.drawRect(10, 10, 100, 50)
gfx.fillRect(20, 20, 80, 30)
gfx.drawLine(0, 0, 400, 240)
gfx.drawCircleAtPoint(200, 120, 40)
gfx.fillCircleAtPoint(200, 120, 40)
gfx.drawText("Hello Playdate!", 10, 10)
end
-- Load from .png file (must be in Source/)
local img = gfx.image.new("images/player") -- no extension needed
-- Draw image
img:draw(x, y)
img:drawCentered(x, y)
-- Flip/transform
img:draw(x, y, gfx.kImageFlippedX)
-- Image tables (sprite sheets)
local table = gfx.imagetable.new("images/walk") -- walk-table-32-32.png format
local frame = table:getImage(frameIndex) -- 1-indexed
-- System fonts
local font = gfx.font.new("fonts/Roobert-10-Bold") -- SDK includes several
gfx.setFont(font)
gfx.drawText("Score: " .. score, 10, 10)
-- Centered text
gfx.drawTextInRect("Hello!", 0, 100, 400, 30, nil, nil, kTextAlignment.center)
-- Create sprite
local playerSprite = gfx.sprite.new()
playerSprite:setImage(gfx.image.new("images/player"))
playerSprite:moveTo(200, 120)
playerSprite:setZIndex(10)
playerSprite:add() -- add to sprite list
-- Custom sprite class (OOP pattern)
class('Player').extends(gfx.sprite)
function Player:init()
Player.super.init(self)
self:setImage(gfx.image.new("images/player"))
self:add()
end
function Player:update()
if playdate.buttonIsPressed(playdate.kButtonRight) then
self:moveBy(2, 0)
end
end
-- In playdate.update():
gfx.sprite.update() -- required every frame
-- Set collision rect
playerSprite:setCollideRect(0, 0, playerSprite:getSize())
-- Query collisions after move
local actualX, actualY, cols, len = playerSprite:moveWithCollisions(newX, newY)
-- Collision response
for i = 1, len do
local col = cols[i]
print("Hit:", col.other:getTag()) -- other sprite's tag
end
-- Sample playback
local sfx = playdate.sound.sampleplayer.new("sounds/jump") -- .wav or .aif
sfx:play()
-- Background music (loops by default)
local music = playdate.sound.fileplayer.new("sounds/bgm")
music:play(0) -- 0 = loop forever
music:setVolume(0.7)
-- Synthesizer (procedural audio)
local synth = playdate.sound.synth.new(playdate.sound.kWaveformSquare)
synth:setFrequency(440)
synth:setVolume(0.5)
synth:playNote("A4", 0.5, 0.25) -- note, volume, duration
import "CoreLibs/timer"
-- One-shot timer (fires after 2000ms)
playdate.timer.performAfterDelay(2000, function()
print("Two seconds passed!")
end)
-- Repeating timer
local t = playdate.timer.new(500, function()
-- fires every 500ms
end)
t.repeats = true
-- Value timer (lerp a value over time)
local vt = playdate.timer.new(1000, 0, 100) -- 1000ms, from 0 to 100
-- In update: use vt.value
-- IMPORTANT: Must call every frame
playdate.timer.updateTimers()
# Source/pdxinfo (required)
name=My Game
author=Your Name
description=A short game description
bundleID=com.yourname.mygame
version=1.0.0
buildNumber=1
imagePath=images/
launchSoundPath=sounds/launch
contentWarning=Contains flashing lights
Required launcher assets (put in images/ or configured imagePath):
launcher/card.png — 350×155 pixelslauncher/card~highlight.png — 350×155 pixels (highlighted state)launcher/icon.png — 32×32 pixelslauncher/icon~highlight.png — 32×32 pixelsgfx.clear() — use gfx.sprite.update() dirty-rect rendering insteadplaydate.display.setRefreshRate(30) if 50fps isn't needed-- Check frame time
playdate.display.setRefreshRate(30)
-- Draw FPS overlay (for profiling)
playdate.drawFPS(0, 0)
-- Respect "Reduce Flashing" system setting
if playdate.getReduceFlashing() then
-- Avoid strobing effects, use slower transitions
end
-- Always provide button fallback for crank actions
if playdate.isCrankDocked() then
showCrankHint = false -- hide crank UI hints
-- Let D-pad substitute for crank
end
import "CoreLibs/ui"
-- Show built-in crank indicator (hint to pull out crank)
local crankIndicator = playdate.ui.crankIndicator
function playdate.update()
if playdate.isCrankDocked() then
crankIndicator:draw() -- draws in corner
end
end
-- Add items to the system pause menu
local menu = playdate.getSystemMenu()
-- Checkmark toggle
local soundItem, err = menu:addCheckmarkMenuItem("Sound", true, function(value)
soundEnabled = value
end)
-- Options list
local diffItem, err = menu:addOptionsMenuItem("Difficulty", {"Easy","Normal","Hard"}, "Normal", function(value)
difficulty = value
end)
-- Simple key-value store (persists between sessions)
-- Save
local data = {score = 1234, level = 5}
playdate.datastore.write(data)
-- Load
local data = playdate.datastore.read()
if data then
score = data.score or 0
end
-- Delete save
playdate.datastore.delete()
-- Simple scene switcher
local currentScene = nil
function switchScene(newScene)
if currentScene and currentScene.leave then
currentScene:leave()
end
gfx.sprite.removeAll()
currentScene = newScene
if currentScene.enter then
currentScene:enter()
end
end
-- Define scenes as tables
local titleScene = {}
function titleScene:enter() ... end
function titleScene:update() ... end
-- In playdate.update():
function playdate.update()
if currentScene and currentScene.update then
currentScene:update()
end
gfx.sprite.update()
playdate.timer.updateTimers()
end
-- Accumulate crank ticks for grid-based movement
local TICKS_PER_STEP = 12 -- degrees per step
local crankAccumulator = 0
function playdate.update()
local delta = playdate.getCrankChange()
crankAccumulator += delta
while crankAccumulator >= TICKS_PER_STEP do
crankAccumulator -= TICKS_PER_STEP
moveRight()
end
while crankAccumulator <= -TICKS_PER_STEP do
crankAccumulator += TICKS_PER_STEP
moveLeft()
end
-- Button fallback (for docked crank)
if playdate.buttonJustPressed(playdate.kButtonLeft) then moveLeft() end
if playdate.buttonJustPressed(playdate.kButtonRight) then moveRight() end
end
references/designing-for-playdate.md — Screen, text, input, audio, UI, launcher guidancereferences/inside-playdate-lua.md — Full Lua API names, file layout, workflow detailsassets/lua-starter/ — Starter project templatedocumentation
Create or expand an Idea.md / IDEA.md file from a rough description, existing repo, conversation history, notes, or other early-stage product inputs. Use when the user asks to "write an Idea.md", "turn this into an idea file", "capture this product idea", "expand this concept", or wants a repo-grounded concept brief before validation, PRD, or implementation work.
development
Write structured implementation plans from specs or requirements before touching code. Use when given a spec, requirements doc, or feature description, when user says "plan this out", "write a plan for", "how should we implement", or before starting any multi-step coding task.
testing
Expert guidance for video editing with ffmpeg, encoding best practices, and quality optimization. Use when working with video files, transcoding, remuxing, encoding settings, color spaces, or troubleshooting video quality issues.
development
Opinionated constraints for building better interfaces with agents. Use when building UI components, implementing animations, designing layouts, reviewing frontend accessibility, or working with Tailwind CSS, motion/react, or accessible primitives like Radix/Base UI.