Night Owl
Adventure

Playdate Pixel Game
Click and Drag to Rotate | Scroll to Zoom

Game Control

Game Controller Diagram

Game Showcase

Playdate Gameplay 1 Playdate Gameplay 2 Playdate Gameplay 3 Playdate Gameplay 4

Source Code

import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"
import "CoreLibs/crank"

local pd  = playdate
local gfx = pd.graphics
local shieldPulseTime = 0 

local SCREEN_W, SCREEN_H = 400, 240

local meteor = {
    active   = false,
    x        = 0,
    y        = 0,
    vx       = 0,
    vy       = 0,
    timer    = 0,
    cooldown = 180,
    trail    = {}
}

local NUM_STARS = 30
local stars = {}

for i = 1, NUM_STARS do
    stars[i] = {
        x     = math.random(0, SCREEN_W-1),
        y     = math.random(0, 200), 
        phase = math.random() * math.pi*2,
        speed = 0.5 + math.random() * 0.8
    }
end

local starSprite = gfx.sprite.new()
starSprite:setSize(SCREEN_W, SCREEN_H)
starSprite:setCenter(0, 0)
starSprite:moveTo(0, 0)

starSprite:setZIndex(-5)

function starSprite:draw()
    for _, s in ipairs(stars) do
        local a = (math.sin(s.phase) + 1) / 2
        if a > 0.2 then
            gfx.setColor(gfx.kColorWhite)
            if a > 0.8 then
                gfx.fillRect(s.x, s.y, 2, 2)
            else
                gfx.drawPixel(s.x, s.y)
            end
        end
    end

   function starSprite:draw()
    for _, s in ipairs(stars) do
        local a = (math.sin(s.phase) + 1) / 2
        if a > 0.2 then
            gfx.setColor(gfx.kColorWhite)
            if a > 0.8 then
                gfx.fillRect(s.x, s.y, 2, 2)
            else
                gfx.drawPixel(s.x, s.y)
            end
        end
    end

    if meteor.active or #meteor.trail > 0 then
        gfx.setColor(gfx.kColorWhite)

        local trailCount = #meteor.trail
        for i, p in ipairs(meteor.trail) do
            local t = (trailCount - i + 1) / trailCount
            local len  = 6 + 10 * t
            local offX = -len * 0.8
            local offY =  len * 0.4

            if t > 0.66 then
                gfx.drawLine(p.x, p.y,       p.x + offX, p.y + offY)
                gfx.drawLine(p.x, p.y + 1,   p.x + offX, p.y + offY + 1)
                gfx.drawLine(p.x, p.y - 1,   p.x + offX, p.y + offY - 1)
            elseif t > 0.33 then
                gfx.drawLine(p.x, p.y,       p.x + offX, p.y + offY)
                gfx.drawLine(p.x, p.y + 1,   p.x + offX, p.y + offY + 1)
            else
                gfx.drawLine(p.x, p.y,       p.x + offX * 0.6, p.y + offY * 0.6)
            end
        end

        if meteor.active then
            gfx.fillRect(meteor.x - 2, meteor.y - 2, 5, 5)
        end
    end
end
end


function starSprite:update()
    for _, s in ipairs(stars) do
        s.phase = s.phase + s.speed
        if s.phase > math.pi * 2 then
            s.phase = s.phase - math.pi * 2
        end
    end

    if meteor.active then
        meteor.x = meteor.x + meteor.vx
        meteor.y = meteor.y + meteor.vy
        meteor.timer = meteor.timer - 1

        table.insert(meteor.trail, 1, { x = meteor.x, y = meteor.y })
        if #meteor.trail > 7 then
            table.remove(meteor.trail)
        end

        if meteor.timer <= 0
           or meteor.x < -30
           or meteor.y > SCREEN_H + 30 then

            meteor.active   = false
            meteor.cooldown = math.random(260, 520)
            meteor.trail    = {}
        end
    else
        if meteor.cooldown > 0 then
            meteor.cooldown = meteor.cooldown - 1
        else
            if math.random() < 0.04 then
                meteor.active = true
                meteor.x      = math.random(320, 420)
                meteor.y      = math.random(0, 60)
                meteor.vx     = -4 - math.random() * 2
                meteor.vy     =  2 + math.random() * 1.5
                meteor.timer  = math.random(45, 70)
                meteor.cooldown = math.random(320, 640)
                meteor.trail  = {}
            end
        end
    end
end


starSprite:add()
gfx.sprite.update()



local owlImage        = gfx.image.new("images/owl")
local bgImage         = gfx.image.new("images/forest1")
local obstacleImage = gfx.image.new("images/rocklittle")
local obstacle = gfx.sprite.new(obstacleImage)
local ow, oh = obstacleImage:getSize()
obstacle:setCollideRect(0, 0, ow, oh)
obstacle:moveTo(420, math.random(50, 190))
obstacle:add()


local titleImage      = gfx.image.new("images/title")
local gameOverImage   = gfx.image.new("images/gameover1")


local function drawHeart(x, y)
    gfx.setColor(gfx.kColorBlack)

    gfx.fillCircleAtPoint(x + 4, y + 4, 4)
    gfx.fillCircleAtPoint(x + 12, y + 4, 4)

    gfx.fillTriangle(
        x,      y + 6,
        x + 16, y + 6,
        x + 8,  y + 16
    )
end

local owl = gfx.sprite.new(owlImage)
owl:moveTo(60, SCREEN_H / 2)
local owlW, owlH = owlImage:getSize()
owl:setCollideRect(0, 0, owlW, owlH)

owl:add()

local bg1 = gfx.sprite.new(bgImage)
bg1:setZIndex(-10)
bg1:moveTo(SCREEN_W / 2, SCREEN_H / 2)
bg1:add()

local bg2 = gfx.sprite.new(bgImage)
bg2:setZIndex(-10)
bg2:moveTo(SCREEN_W * 1.5, SCREEN_H / 2)
bg2:add()

local obstacle = gfx.sprite.new(obstacleImage)
local ow, oh = obstacleImage:getSize()
obstacle:setCollideRect(0, 0, ow, oh)
obstacle:moveTo(420, math.random(50, 190))
obstacle:add()


local gameState = "title"

local score        = 0
local bestScore    = 0
local obstacleSpeed = 3

local lives            = 3
local invincible       = false
local invincibleTimer  = 0

local dashActive           = false
local dashTimer            = 0
local DASH_DURATION_FRAMES = 18

local fadeAlpha = 0
local fading    = false

local blinkTimer = 0

do
    local data = pd.datastore.read()
    if data and data.bestScore then
        bestScore = data.bestScore
    end
end

local function resetRun()
    score          = 0
    obstacleSpeed  = 3
    lives          = 3
    invincible     = false
    invincibleTimer = 0

    owl:moveTo(60, SCREEN_H / 2)
    obstacle:moveTo(420, math.random(50, 190))

    bg1:moveTo(SCREEN_W / 2, SCREEN_H / 2)
    bg2:moveTo(SCREEN_W * 1.5, SCREEN_H / 2)

    fadeAlpha = 1
    fading    = true
    gameState = "active"
end

local function handleCollision()
    if invincible then
        return
    end

    if #owl:overlappingSprites() > 0 then
        lives = lives - 1
        invincible      = true
        invincibleTimer = 60

        owl:moveBy(-10, -10)

        obstacle:moveTo(420, math.random(50, 190))

        if lives <= 0 then
            if score > bestScore then
                bestScore = score
                playdate.datastore.write({ bestScore = bestScore })
            end
            gameState = "gameover"
        end
    end
end

local function updateFade()
    if fading then
        fadeAlpha = fadeAlpha - 0.05
        if fadeAlpha <= 0 then
            fadeAlpha = 0
            fading    = false
        end
    elseif gameState == "gameover" and fadeAlpha < 1 then
        fadeAlpha = fadeAlpha + 0.05
        if fadeAlpha > 1 then
            fadeAlpha = 1
        end
    end
end

local function drawFade()
    if fadeAlpha <= 0 then return end
    gfx.setColor(gfx.kColorBlack)
    gfx.setDitherPattern(fadeAlpha, gfx.image.kDitherTypeBayer8x8)
    gfx.fillRect(0, 0, SCREEN_W, SCREEN_H)
end

local function updateBackground(dt)
    local speed = (gameState == "active") and 3 or 0

    if speed == 0 then
        return
    end

    local dx = -speed

    bg1:moveBy(dx, 0)
    bg2:moveBy(dx, 0)

    if bg1.x < -SCREEN_W / 2 then
        bg1:moveTo(bg2.x + SCREEN_W, SCREEN_H / 2)
    end
    if bg2.x < -SCREEN_W / 2 then
        bg2:moveTo(bg1.x + SCREEN_W, SCREEN_H / 2)
    end
end

local function updateOwl(dt)
    local crankPos = pd.getCrankPosition()
    local dy = 0

    if crankPos > 20 and crankPos < 160 then
        dy = -4
    elseif crankPos > 200 or crankPos < 20 then
        dy = 4
    end

    local newY = owl.y + dy
    if newY < 20 then newY = 20 end
    if newY > SCREEN_H - 20 then newY = SCREEN_H - 20 end
    owl:moveTo(owl.x, newY)
end

local function updateObstacle(dt)
    obstacle:moveBy(-obstacleSpeed, 0)
    if obstacle.x < -20 then
        score = score + 1
        obstacleSpeed = obstacleSpeed + 0.2
        obstacle:moveTo(420, math.random(50, 190))
    end
end

local function drawHUD()
    gfx.setColor(gfx.kColorWhite)
    gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer8x8)
    gfx.fillRoundRect(0, 0, SCREEN_W, 32, 4)

    gfx.setColor(gfx.kColorBlack)
    gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)

    local hudText = string.format("Score: %d   Best: %d", score, bestScore)

    gfx.setColor(gfx.kColorBlack)
    gfx.drawText(hudText, 249, 10)
    gfx.drawText(hudText, 251, 10)
    gfx.drawText(hudText, 250, 9)
    gfx.drawText(hudText, 250, 11)
    gfx.setColor(gfx.kColorBlack)
    gfx.drawText(hudText, 250, 10)
    gfx.drawText(hudText, 250, 10)

    for i = 1, lives do
        drawHeart(10 + (i - 1) * 20, 6)
    end
end


function playdate.update()
    local dt = pd.getElapsedTime()
    pd.resetElapsedTime()
    shieldPulseTime = shieldPulseTime + dt


    gfx.clear(gfx.kColorWhite)
    gfx.sprite.update()

    if gameState == "title" then
        if titleImage then
            titleImage:draw(0, 0)
        end

        blinkTimer = blinkTimer + 1
        if blinkTimer >= 60 then blinkTimer = 0 end
        if blinkTimer < 30 then
            gfx.drawTextAligned("Press A to Start", SCREEN_W / 2, 190, kTextAlignment.center)
        end

    elseif gameState == "active" then
        updateBackground(dt)
        updateOwl(dt)
        updateObstacle(dt)

        if invincible then
            invincibleTimer = invincibleTimer -1
            if invincibleTimer <= 0 then
                invincible = false
            end
        end

        if dashActive then
            dashTimer = dashTimer -1
            if dashTimer <= 0 then
                dashActive = false
                if invincibleTimer <= 0 then
                    invincible = false
                end
            end
        end

        handleCollision()

        drawHUD()

     if dashActive then
    local x, y = owl.x, owl.y

    local pulse = math.sin(shieldPulseTime * 10) * 4

    local baseOuter = 30
    local baseMid   = 24
    local baseInner = 18

    local outerR = baseOuter + pulse
    local midR   = baseMid   + pulse * 0.7
    local innerR = baseInner + pulse * 0.4

    gfx.setColor(gfx.kColorWhite)

    gfx.setDitherPattern(0.25, gfx.image.kDitherTypeBayer8x8)
    gfx.fillCircleAtPoint(x, y, outerR)

    gfx.setDitherPattern(0.45, gfx.image.kDitherTypeBayer8x8)
    gfx.fillCircleAtPoint(x, y, midR)

    gfx.setDitherPattern(0.7, gfx.image.kDitherTypeBayer8x8)
    gfx.fillCircleAtPoint(x, y, innerR)

    gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)
    gfx.drawCircleAtPoint(x, y, outerR)

    gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)
end


    elseif gameState == "paused" then
        drawHUD()

        local boxW, boxH = 200, 50
        local boxX = (SCREEN_W - boxW) / 2
        local boxY = (SCREEN_H - boxH) / 2

        gfx.setColor(gfx.kColorWhite)
        gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer8x8)
        gfx.fillRoundRect(boxX, boxY, boxW, boxH, 8)

        gfx.setColor(gfx.kColorBlack)
        gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)

        local function drawBoldTextAligned(text, x, y)
            gfx.setColor(gfx.kColorBlack)
            gfx.drawTextAligned(text, x-1, y, kTextAlignment.center)
            gfx.drawTextAligned(text, x+1, y, kTextAlignment.center)
            gfx.drawTextAligned(text, x, y-1, kTextAlignment.center)
            gfx.drawTextAligned(text, x, y+1, kTextAlignment.center)
            gfx.drawTextAligned(text, x, y,   kTextAlignment.center)
        end

        drawBoldTextAligned("PAUSED", SCREEN_W / 2, boxY + 6)

        gfx.drawTextAligned("Press B to Resume", SCREEN_W / 2, boxY + 28, kTextAlignment.center)

    elseif gameState == "gameover" then
        if gameOverImage then
            gameOverImage:draw(0, 0)
        else
            gfx.drawTextAligned("GAME OVER", SCREEN_W / 2, 80, kTextAlignment.center)
        end

        gfx.drawTextAligned("Score: " .. score, SCREEN_W / 2, 130, kTextAlignment.center)
        gfx.drawTextAligned("Best: " .. bestScore, SCREEN_W / 2, 150, kTextAlignment.center)
        gfx.drawTextAligned("Press A to Restart", SCREEN_W / 2, 190, kTextAlignment.center)
    end

    updateFade()
    drawFade()
end


function playdate.AButtonDown()
    if gameState == "title" then
        resetRun()
    elseif gameState == "gameover" then
        resetRun()
    elseif gameState == "active" then
        if not dashActive then
            dashActive = true
            dashTimer  = DASH_DURATION_FRAMES

            invincible      = true
            if invincibleTimer < DASH_DURATION_FRAMES then
                invincibleTimer = DASH_DURATION_FRAMES
            end
        end
    end
end


function playdate.BButtonDown()
    if gameState == "active" then
        gameState = "paused"
    elseif gameState == "paused" then
        gameState = "active"
    end
end
Back to Content