Skip to content

Repository files navigation

playdate-juice

Drop-in game-feel modules for Playdate: scene transitions, chiptune music and sound effects, streamed music, tweens, screen shake, particles and animated backgrounds.

Seven independent modules. Take one, take all of them — nothing here imports anything else in the repo. Six are single Lua files; chiptune is a Lua file plus a C extension (chiptune/).

Module Global What it is
transitions.lua Transitions 23 direction-aware scene transitions + a scene-hop plan
jukebox.lua Jukebox streamed music: crossfades, ducking, pause handling
chiptune.lua + chiptune/ Chiptune tracker music and SFX: a C port of a real Game Boy driver, plus an 8-voice SID-style mode and FastTracker 2's sampler; 12 songs, 84 effects
tween.lua Tween 31 easings, sequences, parallel groups, springs — zero per-frame allocation
shake.lua Shake trauma-based screen shake with deterministic noise
particles.lua Particles pooled 1-bit particle system
backgrounds.lua Backgrounds 17 looping procedural backgrounds

Everything is host-testable: lua test/run.lua runs the whole suite with no SDK and no device. That is a deliberate design constraint, not a bonus — see CONVENTIONS.md.

Install

Copy the .lua files you want into your Source/, or take the lot as a submodule:

git submodule add https://github.com/jenissimo/playdate-juice Source/juice
import "juice/chiptune"
import "juice/transitions"

pdc compiles every .lua in your source tree, so a submodule brings its test/ and demo/ along as a few dead .pdz files. Harmless, and the price of not needing a build step.

transitions

import "transitions"

function changeScene()
    Transitions.start("Slide", "fwd", 24)   -- name, direction, frames
    currentScene = newScene
end

function playdate.update()
    if not Transitions.active then handleInput() end
    Transitions.draw(function() currentScene:draw() end)
end

23 effects: Slide, Fade, Dissolve, Circle, Diamonds, Diamond Wave, Triangles, Bubbles, Blinds, Clock Wipe, Wave Wipe, Interleave, Dither Bands, Spiral, Wind, Melt, Shatter, Scanline, Pixel Shift, Hexagons, Diagonal, Paw Walk, Blink.

Scene-hop plans. Rather than picking an effect at each call site, declare what every navigation hop means — and, just as importantly, which hops stay bare:

Transitions.setPlan({
    ["menu>rules"] = { "Paw Walk", "fwd",  16 },
    ["menu>run"]   = { "Blink",    "fwd",  20 },
}, {
    ["loading>game"] = "the board already plays its own entry reveal",
})

Transitions.play("menu>rules")   -- unknown or bare hops are silently no-ops

setPlan validates effect names and refuses a hop listed in both tables, so a typo is a startup error rather than a scene change that quietly does nothing months later.

Paw Walk needs images/paw_print.png and images/paw_plate.png; they load lazily on first use and degrade to an unstamped wipe if missing. Transitions.setImagePath("your/folder/") repoints them.

jukebox

Jukebox.load{ tracks  = { menu = "sound/menu", level = "sound/level" },
              volumes = { menu = 0.45, level = 0.55 } }

Jukebox.play("menu")                      -- no-op if already playing it
Jukebox.switch("menu", "level", 0.35, 0.30)
Jukebox.duck(0.22, 0.15); Jukebox.restore()

Wire Jukebox.pause / Jukebox.resume to playdate.gameWillPause / gameWillResume — without the resume hook the music stays ducked for the rest of the session after the first pause.

Handles the things that get re-derived in every project: not restarting a track that is already playing, not letting two crossfades race, and doing every fade through the fileplayer's own fade argument (which runs on the audio thread) rather than as a per-frame ramp on the busiest frames in the game.

chiptune

Chiptune.load{ songs = { title = "juice/chiptune/music/cloud_garden.gbm" },
               sfx = "juice/chiptune/music/sfx.gbm" }
Chiptune.play("title")
Chiptune.sfx("coin")          -- the music ducks under it
Chiptune.mute({ "wav" })      -- the song keeps time

GBM, a tracker driver for the original Game Boy, ported from SM83 assembly to C and run inside the audio callback, so the tempo never depends on your frame rate. Two formats: format 1 is four Game Boy channels on an emulated DMG APU, held to the original driver write for write (173,000 APU writes match the original ROM's in an emulator); format 2 goes past the Game Boy with up to 8 voices, an oscillator per instrument that a table can switch frame by frame (SID drums), PWM, ring modulation, hard sync and a resonant filter; format 3 adds FastTracker 2's sampler -- 8-bit samples at their own rates, sample offset, volume column, pan, backwards play, a tempo in BPM. It ships with twelve songs, each a style and a set of techniques -- sampled and synthesised drums, arpeggio chords, a 303 acid line, a sync lead, a ring-modulated bell, a reese bass, tremolo by retrigger, and three 90s jungle tracks with breaks chopped by sample offset -- and an 84-effect SFX library (a set of game sounds, and a whoosh each way for every transition), all written as code you can read and change.

It needs its C half built into the game (a few lines of CMake). Without it, tools/chiptune.py render-all turns the same music into ADPCM files for Jukebox. Everything else: chiptune/README.md.

tween

Tween.to(sprite, 30, { x = 200, y = 120 }, "outBack"):onComplete(done)
Tween.sequence(Tween.field(o, 20, "y", 100), Tween.delay(10), Tween.call(fn))
local spring = Tween.newSpring(0, 0.2)

function playdate.update() Tween.update() end

The SDK already has playdate.easingFunctions and playdate.timer. This module exists for what they don't do: run on the host (so your animation logic is testable), step by frame count deterministically, sequence and group, and avoid allocating per frame — measured at 64 bytes for 20 tweens × 5000 updates, against 390 KB for the closure-per-frame equivalent. The easings are in normalised f(t) -> t form so they compose and can live in a data table.

shake, particles, backgrounds

local sh = Shake.new("hit")
sh:add(0.6)
function playdate.update()
    sh:update()
    Shake.apply(sh); drawWorld(); Shake.pop()
end

local ps = Particles.new(200)
ps:emit(x, y, "spark", 12)
ps:update(); ps:draw()

Backgrounds.update(); Backgrounds.draw(1)

Shake is trauma-based with seeded, reproducible noise. Particles preallocates its pool and drops rather than allocating on overflow (sys.dropped tells you to size up). Backgrounds has 17 loops; each wraps its phase to an exact period so a long session never drifts.

Demo

The Zoo: one exhibit per module, with the chiptune songs as its soundtrack.

python tools/build_demo.py            # Lua + the chiptune extension (simulator, and device with arm-none-eabi-gcc)
python tools/build_demo.py --lua-only # no C compiler: runs without chiptune

Then open demo/Juice.pdx. The build needs the Playdate SDK, CMake and a host C compiler for the extension; ARM_GCC_BIN can point at a toolchain that is not on PATH.

Tests

lua test/run.lua                # expect "N checks, 0 failures"
python tools/chiptune.py test   # the C port against the original ROM, formats 2 and 3, the encoder, the songs

License

MIT. The transition driver, the sound engine and the paw/blink effects were extracted from Nyandoku. The chiptune driver, its format, drum recipes and wave synth come from gameboy-lab.

About

Drop-in game-feel modules for Playdate: scene transitions, a procedural sound engine, music, tweens, screen shake, particles and animated backgrounds

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages