---
name: wintermuted/playdate-game-architecture
source: https://app.decimal.ai/s/wintermuted-playdate-game-architecture@1/SKILL.md
source_sha256: 6771ebcf8a34
---

# Playdate Game Architecture

## Push-Down State Machine

The recommended pattern for Playdate games is a push-down automaton managing a stack of screens. This enables nested state — pausing to show a menu without destroying the game state beneath.

```lua
-- src/core/StateMachine.lua
StateMachine = {}
StateMachine.__index = StateMachine

function StateMachine.new()
  return setmetatable({ stack = {} }, StateMachine)
end

-- Push a new state; pause the current one
function StateMachine:push(state)
  local current = self.stack[#self.stack]
  if current and current.onPause then current:onPause() end
  table.insert(self.stack, state)
  if state.onEnter then state:onEnter() end
end

-- Pop current state; resume the one below
function StateMachine:pop()
  local current = table.remove(self.stack)
  if current and current.onExit then current:onExit() end
  local next = self.stack[#self.stack]
  if next and next.onResume then next:onResume() end
end

-- Replace current state (no stack growth)
function StateMachine:swap(state)
  local current = table.remove(self.stack)
  if current and current.onExit then current:onExit() end
  table.insert(self.stack, state)
  if state.onEnter then state:onEnter() end
end

function StateMachine:update()
  local s = self.stack[#self.stack]
  if s and s.update then s:update() end
end

function StateMachine:draw()
  local s = self.stack[#self.stack]
  if s and s.draw then s:draw() end
end
```

## Screen Lifecycle Hooks

| Hook | Called when |
|------|-------------|
| `onEnter()` | Screen pushed onto the stack |
| `onExit()` | Screen popped off the stack |
| `onPause()` | Another screen pushed on top |
| `onResume()` | Top screen popped; this screen is now active |
| `update()` | Every frame (30 FPS), delegated by the state machine |
| `draw()` | Every frame (or on demand) |

```lua
-- src/screens/TitleScreen.lua
TitleScreen = {}
TitleScreen.__index = TitleScreen

function TitleScreen.new()
  return setmetatable({}, TitleScreen)
end

function TitleScreen:onEnter()
  -- initialize input handlers, UI state
end

function TitleScreen:onExit()
  -- cleanup
end

function TitleScreen:update()
  if playdate.buttonJustPressed(playdate.kButtonA) then
    Game:push(BattleScreen.new())
  end
end
```

## Module Separation Rule

| Layer | May call `playdate.*`? | Testable on desktop Lua? |
|-------|----------------------|-------------------------|
| `models/` | **No** | Yes |
| `systems/` | **No** | Yes |
| `screens/` | Yes | No |
| `ui/` | Yes | No |
| `persistence/` | Yes | No |

**Never call `playdate.*` from models or systems.** This rule enables unit tests to run on desktop Lua without the simulator.

## Recommended Directory Structure

```
src/
  main.lua              Entry point: init Game (StateMachine), import all modules, push first screen
  core/
    StateMachine.lua    Push-down automaton
  screens/              Title, Battle, Result — Playdate API consumers, own the game loop per state
  models/               Pure data + logic (Actor, Battle) — no Playdate API
  systems/              Stateless logic operating on models — no Playdate API
  ui/                   Rendering helpers — Playdate API consumers
  data/                 Lua-native content tables (enemies, items, encounters)
  persistence/          SaveManager — wraps playdate.datastore
tests/
  *_test.lua            Desktop-runnable unit tests (models + systems only)
```

## 30 FPS Budget

`playdate.update()` is called 30 times per second. Each call must complete within ~33ms.

- Profile with `playdate.getMemoryStats()` and `playdate.getStats()`
- Avoid allocating new tables in the hot update loop — reuse or pool objects
- `playdate.graphics.sprite.update()` batches all active sprite updates — call once per frame

## main.lua Pattern

```lua
import "CoreLibs/sprites"
import "CoreLibs/graphics"
import "CoreLibs/timer"
import "core/StateMachine"
import "screens/TitleScreen"
-- import all other modules...

Game = StateMachine.new()
Game:push(TitleScreen.new())

function playdate.update()
  Game:update()
  playdate.graphics.sprite.update()
  playdate.timer.updateTimers()
end
```

## Data Pattern

Game content lives as plain Lua tables in `src/data/`, not JSON or YAML:

```lua
-- src/data/enemies.lua
ENEMY_DEFS = {
  goblin_thug = { name = "Goblin Thug", maxHP = 30, speed = 4, attack = 8 },
  goblin_rogue = { name = "Goblin Rogue", maxHP = 20, speed = 8, attack = 10 },
}
```

This avoids a JSON parsing step and keeps content editable without a pipeline.