
Love2d Gamedev
- 79 installs
- 18 repo stars
- Updated January 30, 2026
- chongdashu/love2d-pocket-bomber-game
Build 2D games with the Love2D framework covering game loop, graphics, animation, tilemaps, collision, audio, and iOS deployment.
About
Covers Love2D game development from prototype to release including core architecture, sprites, collision, audio, and mobile deployment. Used when a developer builds a Love2D game or ports it to iOS.
- Full Love2D loop, graphics, and collision reference
- Includes iOS deployment guidance
Love2d Gamedev by the numbers
- 79 all-time installs (skills.sh)
- Ranked #137 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/chongdashu/love2d-pocket-bomber-game --skill love2d-gamedevAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 79 |
|---|---|
| repo stars | ★ 18 |
| Last updated | January 30, 2026 |
| Repository | chongdashu/love2d-pocket-bomber-game ↗ |
What it does
Build 2D games with the Love2D framework covering game loop, graphics, animation, tilemaps, collision, audio, and iOS deployment.
Files
Love2D Game Development
Build polished 2D games with the Love2D framework—from first prototype to iOS release.
Quick Reference
| Topic | When to Use |
|---|---|
| Core Architecture | Understanding game loop, callbacks, modules |
| Graphics & Drawing | Images, colors, transforms, screen adaptation |
| Animation | Sprite sheets, quads, frame timing |
| Tiles & Maps | Tile-based levels, map loading |
| Collision | AABB, circle, and separating axis collision |
| Audio | Sound effects, music, volume control |
| Project Structure | File organization, conf.lua, distribution |
| Libraries | Popular community libraries |
| iOS Deployment | Build, touch controls, App Store |
---
The Love2D Game Loop
Every Love2D game follows this pattern:
function love.load()
-- Called once at startup
-- Load assets, initialize state
end
function love.update(dt)
-- Called every frame
-- dt = time since last frame (seconds)
-- Update game logic here
end
function love.draw()
-- Called every frame after update
-- All rendering happens here
endKey insight: dt (delta time) ensures consistent speed across frame rates.
-- WRONG: Speed varies with frame rate
player.x = player.x + 5
-- RIGHT: 200 pixels per second, regardless of FPS
player.x = player.x + 200 * dt---
Essential Patterns
Loading and Drawing Images
function love.load()
playerImage = love.graphics.newImage("player.png")
end
function love.draw()
love.graphics.draw(playerImage, x, y)
-- Full signature: draw(image, x, y, rotation, scaleX, scaleY, originX, originY)
endInput Handling
-- Polling (check every frame)
function love.update(dt)
if love.keyboard.isDown("left") then
player.x = player.x - 200 * dt
end
end
-- Event-based (fires once per press)
function love.keypressed(key)
if key == "space" then
player:jump()
end
endScreen-Adaptive Positioning
Never hard-code screen dimensions:
function love.load()
screenW, screenH = love.graphics.getDimensions()
end
function love.resize(w, h)
screenW, screenH = w, h
end
function love.draw()
-- Position relative to screen
local centerX = screenW / 2
local bottomY = screenH - 50
end---
Core Modules
| Module | Purpose | Key Functions |
|---|---|---|
love.graphics | Rendering | draw, rectangle, circle, print, setColor |
love.audio | Sound | newSource, play, stop, setVolume |
love.keyboard | Keyboard input | isDown, keypressed callback |
love.mouse | Mouse input | getPosition, isDown, callbacks |
love.touch | Touch input | touchpressed, touchmoved, touchreleased |
love.filesystem | File I/O | read, write, getInfo |
love.timer | Timing | getDelta, getTime, getFPS |
love.window | Window control | setMode, getMode, setTitle |
love.physics | Box2D physics | newWorld, newBody, newFixture |
---
Project Setup
Minimal Project
my-game/
├── main.lua # Entry point (required)
└── conf.lua # Configuration (optional but recommended)conf.lua Template
function love.conf(t)
t.window.title = "My Game"
t.window.width = 800
t.window.height = 600
t.version = "11.5" -- Love2D version
t.console = true -- Enable console on Windows
-- Disable unused modules for faster startup
t.modules.joystick = false
t.modules.physics = false
endRunning the Game
# macOS
/Applications/love.app/Contents/MacOS/love /path/to/game
# Create alias in ~/.zshrc
alias love="/Applications/love.app/Contents/MacOS/love"---
Common Patterns
State Management
local gameState = "menu" -- menu, playing, paused, gameover
function love.update(dt)
if gameState == "playing" then
updateGame(dt)
end
end
function love.draw()
if gameState == "menu" then
drawMenu()
elseif gameState == "playing" then
drawGame()
end
endObject-Oriented Entities
local Player = {}
Player.__index = Player
function Player:new(x, y)
return setmetatable({
x = x, y = y,
speed = 200,
image = love.graphics.newImage("player.png")
}, Player)
end
function Player:update(dt)
if love.keyboard.isDown("right") then
self.x = self.x + self.speed * dt
end
end
function Player:draw()
love.graphics.draw(self.image, self.x, self.y)
end
return PlayerCamera/Viewport
local camera = { x = 0, y = 0 }
function love.draw()
love.graphics.push()
love.graphics.translate(-camera.x, -camera.y)
-- Draw world objects here
drawWorld()
love.graphics.pop()
-- Draw UI here (not affected by camera)
drawUI()
end---
Anti-Patterns to Avoid
| Don't | Why | Do Instead |
|---|---|---|
| Hard-code coordinates | Breaks on different screens | Use percentages or anchors |
Forget dt in movement | Speed varies with frame rate | Multiply by dt |
Load assets in update/draw | Loads every frame, kills performance | Load once in love.load |
| Use global variables everywhere | Hard to track, name collisions | Use local variables and modules |
| Test only on desktop | Touch behaves differently | Test on device early |
---
iOS Development
For iOS deployment, see the iOS Overview which covers:
- Build Setup - Xcode project, libraries, signing
- Touch Controls - Virtual joysticks, buttons, gestures
- Xcode Project Structure - Manual pbxproj editing
Quick iOS checklist: 1. Download Love2D iOS source + Apple libraries 2. Copy libraries to Xcode project 3. Fix deployment target (8.0 → 15.0) 4. Create game.love (zip of Lua files) 5. Add game.love to Xcode bundle resources 6. Configure signing and deploy
---
Philosophy
Love2D makes game development joyful through simplicity:
1. Lua is approachable - Dynamic typing, clean syntax, fast iteration 2. The API is consistent - Functions follow predictable patterns 3. You own the game loop - No hidden magic, full control 4. Cross-platform by default - Same code runs on Windows, macOS, Linux, iOS, Android
The goal isn't just "make it work." The goal is "make it feel great."
Smooth animations, responsive controls, adaptive layouts—that's the standard for polished games.
Animation
Sprite sheet animation using quads and frame timing.
Concept
Animation = cycling through images (frames) over time.
Two approaches: 1. Multiple images: Load separate files for each frame 2. Sprite sheets: One image containing all frames, use quads to draw portions
Sprite sheets are preferred for performance and organization.
Basic Frame Animation
Using Separate Images
function love.load()
frames = {}
for i = 1, 5 do
frames[i] = love.graphics.newImage("walk" .. i .. ".png")
end
currentFrame = 1
frameTime = 0
frameDuration = 0.1 -- 10 FPS animation
end
function love.update(dt)
frameTime = frameTime + dt
if frameTime >= frameDuration then
frameTime = frameTime - frameDuration
currentFrame = currentFrame + 1
if currentFrame > #frames then
currentFrame = 1
end
end
end
function love.draw()
love.graphics.draw(frames[currentFrame], 100, 100)
endQuads (Sprite Sheets)
A quad defines a rectangular region of an image.
Creating Quads
love.graphics.newQuad(x, y, width, height, imageWidth, imageHeight)x, y: Top-left corner of the regionwidth, height: Size of the regionimageWidth, imageHeight: Full image dimensions
Example: 4-Frame Animation
Given a sprite sheet with frames arranged horizontally:
[Frame1][Frame2][Frame3][Frame4]
0-32 32-64 64-96 96-128function love.load()
spriteSheet = love.graphics.newImage("walk.png")
local frameW = 32
local frameH = 32
local imgW = spriteSheet:getWidth()
local imgH = spriteSheet:getHeight()
frames = {}
for i = 0, 3 do
frames[i + 1] = love.graphics.newQuad(
i * frameW, 0, -- Position in sheet
frameW, frameH, -- Frame size
imgW, imgH -- Image dimensions
)
end
currentFrame = 1
frameTime = 0
end
function love.update(dt)
frameTime = frameTime + dt
if frameTime >= 0.1 then
frameTime = 0
currentFrame = currentFrame % #frames + 1
end
end
function love.draw()
love.graphics.draw(spriteSheet, frames[currentFrame], 100, 100)
endMulti-Row Sprite Sheets
For sheets with multiple rows:
function loadFrames(image, frameW, frameH, numFrames)
local frames = {}
local imgW = image:getWidth()
local imgH = image:getHeight()
local cols = math.floor(imgW / frameW)
for i = 0, numFrames - 1 do
local col = i % cols
local row = math.floor(i / cols)
frames[i + 1] = love.graphics.newQuad(
col * frameW, row * frameH,
frameW, frameH,
imgW, imgH
)
end
return frames
end
function love.load()
sheet = love.graphics.newImage("character.png")
walkFrames = loadFrames(sheet, 64, 64, 8)
endAnimation Class
A reusable animation system:
local Animation = {}
Animation.__index = Animation
function Animation:new(image, frameWidth, frameHeight, frameDuration, frameCount)
local anim = setmetatable({}, Animation)
anim.image = image
anim.frameWidth = frameWidth
anim.frameHeight = frameHeight
anim.frameDuration = frameDuration or 0.1
-- Generate quads
anim.frames = {}
local imgW, imgH = image:getDimensions()
local cols = math.floor(imgW / frameWidth)
for i = 0, (frameCount or cols) - 1 do
local col = i % cols
local row = math.floor(i / cols)
anim.frames[i + 1] = love.graphics.newQuad(
col * frameWidth, row * frameHeight,
frameWidth, frameHeight,
imgW, imgH
)
end
anim.currentFrame = 1
anim.timer = 0
anim.playing = true
anim.looping = true
return anim
end
function Animation:update(dt)
if not self.playing then return end
self.timer = self.timer + dt
if self.timer >= self.frameDuration then
self.timer = self.timer - self.frameDuration
self.currentFrame = self.currentFrame + 1
if self.currentFrame > #self.frames then
if self.looping then
self.currentFrame = 1
else
self.currentFrame = #self.frames
self.playing = false
end
end
end
end
function Animation:draw(x, y, r, sx, sy, ox, oy)
love.graphics.draw(
self.image,
self.frames[self.currentFrame],
x, y, r or 0, sx or 1, sy or 1,
ox or 0, oy or 0
)
end
function Animation:reset()
self.currentFrame = 1
self.timer = 0
self.playing = true
end
function Animation:stop()
self.playing = false
end
function Animation:play()
self.playing = true
end
function Animation:setFrame(frame)
self.currentFrame = math.max(1, math.min(frame, #self.frames))
end
return AnimationUsing the Animation Class
local Animation = require("animation")
function love.load()
local sheet = love.graphics.newImage("player.png")
walkAnim = Animation:new(sheet, 32, 32, 0.1, 4)
walkAnim.looping = true
jumpAnim = Animation:new(sheet, 32, 32, 0.15, 3)
jumpAnim.looping = false
end
function love.update(dt)
walkAnim:update(dt)
end
function love.draw()
walkAnim:draw(100, 100)
endMultiple Animations per Entity
local Player = {}
Player.__index = Player
function Player:new(x, y, sheet)
local p = setmetatable({}, Player)
p.x = x
p.y = y
p.direction = 1 -- 1 = right, -1 = left
-- Define animation regions (assuming organized sprite sheet)
p.animations = {
idle = Animation:new(sheet, 32, 32, 0.2, 2),
walk = Animation:new(sheet, 32, 32, 0.1, 4),
jump = Animation:new(sheet, 32, 32, 0.15, 3),
}
p.currentAnim = "idle"
return p
end
function Player:setAnimation(name)
if self.currentAnim ~= name then
self.currentAnim = name
self.animations[name]:reset()
end
end
function Player:update(dt)
-- State logic determines animation
if self.jumping then
self:setAnimation("jump")
elseif math.abs(self.vx) > 0 then
self:setAnimation("walk")
else
self:setAnimation("idle")
end
self.animations[self.currentAnim]:update(dt)
end
function Player:draw()
local anim = self.animations[self.currentAnim]
anim:draw(self.x, self.y, 0, self.direction, 1, 16, 16)
endBleeding Fix
When scaling or rotating sprites, adjacent pixels in the sheet can "bleed" into view.
Solution: Add 1-pixel transparent border around each frame.
Before: [Frame1][Frame2][Frame3]
After: [.Frame1.][.Frame2.][.Frame3.]
(. = transparent pixel)Adjust quad positions to skip the border:
local border = 1
local frameW = 32
local frameH = 32
local paddedW = frameW + border * 2
local paddedH = frameH + border * 2
for i = 0, numFrames - 1 do
quads[i + 1] = love.graphics.newQuad(
border + i * paddedW,
border,
frameW, frameH,
imgW, imgH
)
endAnimation Speed Control
-- Slow down animation
anim.frameDuration = 0.2 -- Half speed
-- Speed up animation
anim.frameDuration = 0.05 -- Double speed
-- Variable speed based on game state
function Player:update(dt)
if self.running then
self.walkAnim.frameDuration = 0.05
else
self.walkAnim.frameDuration = 0.1
end
endAnimation Events
Trigger actions on specific frames:
function Animation:update(dt)
local previousFrame = self.currentFrame
-- ... normal update logic ...
if self.currentFrame ~= previousFrame then
if self.onFrameChange then
self.onFrameChange(self.currentFrame)
end
end
end
-- Usage
attackAnim.onFrameChange = function(frame)
if frame == 3 then
dealDamage()
end
endAudio
Sound effects, music, and audio management in Love2D.
Loading Audio
function love.load()
-- "static": Load entirely into memory (good for short sounds)
jumpSound = love.audio.newSource("sounds/jump.wav", "static")
-- "stream": Stream from disk (good for music)
music = love.audio.newSource("music/theme.ogg", "stream")
endFormats supported: WAV, OGG, MP3, FLAC
Recommendations:
- Sound effects: WAV (uncompressed, fast loading) or OGG (compressed)
- Music: OGG (good compression, quality, and seeking)
Playing Audio
Basic Playback
-- Play sound
jumpSound:play()
-- Play from beginning (even if already playing)
jumpSound:stop()
jumpSound:play()
-- Or use clone for overlapping sounds
jumpSound:clone():play()Stopping and Pausing
music:play()
music:pause() -- Can be resumed
music:stop() -- Resets to beginning
music:rewind() -- Same as stop for most sourcesChecking State
if music:isPlaying() then
-- Audio is currently playing
end
-- Get playback position (seconds)
local position = music:tell()
-- Seek to position
music:seek(30) -- Jump to 30 secondsVolume Control
-- Per-source volume (0 to 1)
music:setVolume(0.5)
-- Global volume
love.audio.setVolume(0.8)
-- Get current volume
local vol = music:getVolume()Looping
-- Loop forever
music:setLooping(true)
-- Check if looping
if music:isLooping() then
-- ...
endPitch
Change playback speed (also affects pitch):
-- Normal pitch
sound:setPitch(1.0)
-- Higher pitch (faster)
sound:setPitch(1.5)
-- Lower pitch (slower)
sound:setPitch(0.8)
-- Random variation for variety
sound:setPitch(0.9 + math.random() * 0.2)Sound Manager
A centralized audio manager:
local SoundManager = {
sounds = {},
music = nil,
musicVolume = 0.7,
sfxVolume = 1.0,
muted = false
}
function SoundManager:load(name, path, sourceType)
sourceType = sourceType or "static"
self.sounds[name] = love.audio.newSource(path, sourceType)
end
function SoundManager:play(name)
if self.muted then return end
local sound = self.sounds[name]
if sound then
-- Clone for overlapping playback
local instance = sound:clone()
instance:setVolume(self.sfxVolume)
instance:play()
return instance
end
end
function SoundManager:playMusic(name, loop)
if self.music then
self.music:stop()
end
self.music = self.sounds[name]
if self.music then
self.music:setVolume(self.musicVolume)
self.music:setLooping(loop ~= false)
self.music:play()
end
end
function SoundManager:stopMusic()
if self.music then
self.music:stop()
end
end
function SoundManager:setMusicVolume(vol)
self.musicVolume = vol
if self.music then
self.music:setVolume(vol)
end
end
function SoundManager:setSFXVolume(vol)
self.sfxVolume = vol
end
function SoundManager:mute()
self.muted = true
if self.music then
self.music:pause()
end
end
function SoundManager:unmute()
self.muted = false
if self.music then
self.music:play()
end
end
return SoundManagerUsage
local Sound = require("soundmanager")
function love.load()
Sound:load("jump", "sounds/jump.wav")
Sound:load("shoot", "sounds/shoot.wav")
Sound:load("music", "music/theme.ogg", "stream")
Sound:playMusic("music")
end
function Player:jump()
Sound:play("jump")
-- ...
endPositional Audio (3D Sound)
For spatial audio effects:
function love.load()
-- Set listener position (usually the player/camera)
love.audio.setPosition(0, 0, 0)
end
function love.update(dt)
-- Update listener to follow player
love.audio.setPosition(player.x, player.y, 0)
end
function Enemy:playSound()
local sound = explosionSound:clone()
sound:setPosition(self.x, self.y, 0)
sound:play()
endDistance Attenuation
sound:setAttenuationDistances(100, 500)
-- Sound starts fading at 100 units, silent at 500 unitsAudio Pools
For frequently played sounds (bullets, footsteps), pre-create instances:
local AudioPool = {}
AudioPool.__index = AudioPool
function AudioPool:new(source, size)
local pool = setmetatable({
source = source,
instances = {},
index = 1
}, AudioPool)
for i = 1, size do
pool.instances[i] = source:clone()
end
return pool
end
function AudioPool:play()
local instance = self.instances[self.index]
-- Stop if still playing
instance:stop()
instance:play()
-- Round-robin to next instance
self.index = self.index % #self.instances + 1
return instance
end
-- Usage
function love.load()
local bulletSource = love.audio.newSource("bullet.wav", "static")
bulletPool = AudioPool:new(bulletSource, 10)
end
function shootBullet()
bulletPool:play()
endFade Effects
Fade Out
local fadeOutSound = nil
local fadeOutDuration = 1
local fadeOutTimer = 0
local fadeOutStartVolume = 0
function startFadeOut(sound, duration)
fadeOutSound = sound
fadeOutDuration = duration
fadeOutTimer = 0
fadeOutStartVolume = sound:getVolume()
end
function love.update(dt)
if fadeOutSound then
fadeOutTimer = fadeOutTimer + dt
local progress = fadeOutTimer / fadeOutDuration
if progress >= 1 then
fadeOutSound:stop()
fadeOutSound = nil
else
fadeOutSound:setVolume(fadeOutStartVolume * (1 - progress))
end
end
endCrossfade
function crossfade(fromMusic, toMusic, duration)
local timer = 0
local fromVol = fromMusic:getVolume()
toMusic:setVolume(0)
toMusic:play()
-- Use a timer callback or coroutine
-- Simplified version:
return function(dt)
timer = timer + dt
local progress = math.min(timer / duration, 1)
fromMusic:setVolume(fromVol * (1 - progress))
toMusic:setVolume(fromVol * progress)
if progress >= 1 then
fromMusic:stop()
return true -- Done
end
return false
end
endBest Practices
1. Use "static" for short sounds: Faster playback, more memory 2. Use "stream" for music: Less memory, slight CPU overhead 3. Clone for overlapping sounds: Same sound can play multiple times 4. Pre-load in love.load: Don't load during gameplay 5. Pool frequently-used sounds: Avoid creating garbage 6. Normalize volume levels: Keep sounds at similar perceived loudness 7. Use OGG for distribution: Good compression, wide support
Common Issues
Sound Not Playing
-- Check if audio module is available
if love.audio then
sound:play()
end
-- Check source validity
if sound and sound:isPlaying() == false then
sound:play()
endAudio Latency
Streaming sources have slight latency. For time-critical sounds (like footsteps synced to animation), use static sources.
Too Many Sources
There's a limit to simultaneous audio sources. Use pools and stop sounds that are no longer needed:
function cleanup()
for _, sound in pairs(activeSounds) do
if not sound:isPlaying() then
sound:release()
end
end
endCollision Detection
Common collision detection patterns for 2D games.
AABB (Axis-Aligned Bounding Box)
The simplest and most common collision check. Works for rectangles that don't rotate.
Basic AABB Check
function checkCollision(x1, y1, w1, h1, x2, y2, w2, h2)
return x1 < x2 + w2 and
x2 < x1 + w1 and
y1 < y2 + h2 and
y2 < y1 + h1
end
-- Usage
if checkCollision(player.x, player.y, player.w, player.h,
enemy.x, enemy.y, enemy.w, enemy.h) then
player:takeDamage()
endObject-Based Version
function collides(a, b)
return a.x < b.x + b.w and
b.x < a.x + a.w and
a.y < b.y + b.h and
b.y < a.y + a.h
end
-- Usage
if collides(player, enemy) then
handleCollision()
endCircle Collision
Better for round objects, projectiles, or when you want more forgiving hit detection.
function circleCollision(x1, y1, r1, x2, y2, r2)
local dx = x2 - x1
local dy = y2 - y1
local distance = math.sqrt(dx * dx + dy * dy)
return distance < r1 + r2
end
-- Usage
if circleCollision(bullet.x, bullet.y, bullet.radius,
target.x, target.y, target.radius) then
target:hit()
endDistance Without Square Root
For performance-critical code, compare squared distances:
function circleCollisionFast(x1, y1, r1, x2, y2, r2)
local dx = x2 - x1
local dy = y2 - y1
local distSq = dx * dx + dy * dy
local radiiSum = r1 + r2
return distSq < radiiSum * radiiSum
endPoint in Rectangle
Check if a point (like mouse or touch) is inside a rectangle:
function pointInRect(px, py, rx, ry, rw, rh)
return px >= rx and px <= rx + rw and
py >= ry and py <= ry + rh
end
-- Usage (button click)
function love.mousepressed(x, y, button)
if pointInRect(x, y, button.x, button.y, button.w, button.h) then
button:click()
end
endPoint in Circle
function pointInCircle(px, py, cx, cy, r)
local dx = px - cx
local dy = py - cy
return dx * dx + dy * dy <= r * r
endCollision Response
Detecting collision is only half the problem. You also need to respond appropriately.
Stop Movement
function Player:update(dt)
local newX = self.x + self.vx * dt
local newY = self.y + self.vy * dt
-- Check X movement separately
if not checkWorldCollision(newX, self.y, self.w, self.h) then
self.x = newX
else
self.vx = 0
end
-- Check Y movement separately
if not checkWorldCollision(self.x, newY, self.w, self.h) then
self.y = newY
else
self.vy = 0
end
endPush Out (Minimum Translation Vector)
Calculate how to separate overlapping objects:
function getMTV(a, b)
-- Calculate overlap on each axis
local overlapX = math.min(a.x + a.w, b.x + b.w) - math.max(a.x, b.x)
local overlapY = math.min(a.y + a.h, b.y + b.h) - math.max(a.y, b.y)
if overlapX <= 0 or overlapY <= 0 then
return nil -- No collision
end
-- Push out on shortest axis
if overlapX < overlapY then
if a.x < b.x then
return -overlapX, 0 -- Push left
else
return overlapX, 0 -- Push right
end
else
if a.y < b.y then
return 0, -overlapY -- Push up
else
return 0, overlapY -- Push down
end
end
end
-- Usage
local pushX, pushY = getMTV(player, wall)
if pushX then
player.x = player.x + pushX
player.y = player.y + pushY
endCollision Layers
Not everything collides with everything:
local LAYER = {
PLAYER = 1,
ENEMY = 2,
PLAYER_BULLET = 4,
ENEMY_BULLET = 8,
WALL = 16,
}
-- Define what collides with what
local collisionMatrix = {
[LAYER.PLAYER] = LAYER.ENEMY + LAYER.ENEMY_BULLET + LAYER.WALL,
[LAYER.ENEMY] = LAYER.PLAYER_BULLET + LAYER.WALL,
[LAYER.PLAYER_BULLET] = LAYER.ENEMY + LAYER.WALL,
[LAYER.ENEMY_BULLET] = LAYER.PLAYER + LAYER.WALL,
}
function shouldCollide(layerA, layerB)
local mask = collisionMatrix[layerA] or 0
return bit.band(mask, layerB) > 0
endSpatial Partitioning
For many objects, checking every pair is slow (O(n²)). Use spatial partitioning.
Grid-Based Partitioning
local SpatialGrid = {}
SpatialGrid.__index = SpatialGrid
function SpatialGrid:new(cellSize)
return setmetatable({
cellSize = cellSize,
cells = {}
}, SpatialGrid)
end
function SpatialGrid:clear()
self.cells = {}
end
function SpatialGrid:getCellKey(x, y)
local cx = math.floor(x / self.cellSize)
local cy = math.floor(y / self.cellSize)
return cx .. "," .. cy
end
function SpatialGrid:insert(obj)
-- Insert into all cells the object overlaps
local x1 = math.floor(obj.x / self.cellSize)
local y1 = math.floor(obj.y / self.cellSize)
local x2 = math.floor((obj.x + obj.w) / self.cellSize)
local y2 = math.floor((obj.y + obj.h) / self.cellSize)
for cx = x1, x2 do
for cy = y1, y2 do
local key = cx .. "," .. cy
if not self.cells[key] then
self.cells[key] = {}
end
table.insert(self.cells[key], obj)
end
end
end
function SpatialGrid:getNearby(obj)
local nearby = {}
local seen = {}
local x1 = math.floor(obj.x / self.cellSize)
local y1 = math.floor(obj.y / self.cellSize)
local x2 = math.floor((obj.x + obj.w) / self.cellSize)
local y2 = math.floor((obj.y + obj.h) / self.cellSize)
for cx = x1, x2 do
for cy = y1, y2 do
local key = cx .. "," .. cy
if self.cells[key] then
for _, other in ipairs(self.cells[key]) do
if other ~= obj and not seen[other] then
seen[other] = true
table.insert(nearby, other)
end
end
end
end
end
return nearby
endUsage
local grid = SpatialGrid:new(64) -- 64px cells
function love.update(dt)
grid:clear()
-- Insert all objects
for _, obj in ipairs(gameObjects) do
grid:insert(obj)
end
-- Check collisions
for _, obj in ipairs(gameObjects) do
local nearby = grid:getNearby(obj)
for _, other in ipairs(nearby) do
if collides(obj, other) then
handleCollision(obj, other)
end
end
end
endPlatformer Collision
Special considerations for platformers:
One-Way Platforms
function checkPlatformCollision(player, platform)
-- Only collide when falling down onto the platform
if player.vy <= 0 then return false end
-- Only collide when player's feet were above the platform
local prevBottom = player.prevY + player.h
local currBottom = player.y + player.h
if prevBottom <= platform.y and currBottom > platform.y then
return player.x + player.w > platform.x and
player.x < platform.x + platform.w
end
return false
endGround Detection
function Player:isOnGround()
-- Check a thin rectangle below the player
local groundCheckBox = {
x = self.x + 2,
y = self.y + self.h,
w = self.w - 4,
h = 2
}
for _, platform in ipairs(platforms) do
if collides(groundCheckBox, platform) then
return true
end
end
return false
endContinuous Collision Detection
For fast-moving objects that might tunnel through thin walls:
function sweepAABB(obj, vx, vy, wall)
-- Calculate time of collision on each axis
local xEntry, xExit, yEntry, yExit
if vx > 0 then
xEntry = (wall.x - (obj.x + obj.w)) / vx
xExit = (wall.x + wall.w - obj.x) / vx
elseif vx < 0 then
xEntry = (wall.x + wall.w - obj.x) / vx
xExit = (wall.x - (obj.x + obj.w)) / vx
else
xEntry = -math.huge
xExit = math.huge
end
-- Similar for Y axis...
local entryTime = math.max(xEntry, yEntry)
local exitTime = math.min(xExit, yExit)
if entryTime > exitTime or entryTime < 0 or entryTime > 1 then
return nil -- No collision this frame
end
return entryTime -- Time of collision (0-1)
endBest Practices
1. Separate detection from response: Check collision first, then decide what to do 2. Check axes separately: For AABB, this allows sliding along walls 3. Use appropriate shapes: Circles for round things, boxes for square things 4. Spatial partitioning: Required for many objects (>50) 5. Debug visualization: Draw collision boxes during development 6. Margin of error: Add small tolerances to prevent floating-point issues
Debug Visualization
local debugCollision = true
function drawCollisionBoxes()
if not debugCollision then return end
love.graphics.setColor(1, 0, 0, 0.3)
for _, obj in ipairs(gameObjects) do
love.graphics.rectangle("fill", obj.x, obj.y, obj.w, obj.h)
end
love.graphics.setColor(1, 1, 1, 1)
endCore Architecture
Understanding Love2D's game loop, callbacks, and module system.
The Game Loop
Love2D manages the main loop internally. You implement callbacks that it calls:
love.load() → [love.update() → love.draw()] → repeatlove.load()
Called exactly once when the game starts. Use for:
- Loading images, sounds, fonts
- Initializing game state
- Setting up data structures
function love.load()
player = {
x = 100,
y = 100,
speed = 200,
image = love.graphics.newImage("player.png")
}
enemies = {}
score = 0
endlove.update(dt)
Called every frame before drawing. The dt parameter is delta time—seconds since the last frame.
Why dt matters: Without dt, game speed depends on frame rate. A 60 FPS machine runs twice as fast as 30 FPS.
function love.update(dt)
-- dt ≈ 0.016 at 60 FPS
-- dt ≈ 0.033 at 30 FPS
-- 200 pixels per second, regardless of frame rate
player.x = player.x + 200 * dt
endlove.draw()
Called every frame after update. All rendering happens here.
Important: Drawing order matters. Later draws appear on top.
function love.draw()
-- Background first
love.graphics.draw(backgroundImage, 0, 0)
-- Game objects
love.graphics.draw(player.image, player.x, player.y)
-- UI on top
love.graphics.print("Score: " .. score, 10, 10)
endInput Callbacks
Keyboard
function love.keypressed(key, scancode, isrepeat)
-- key: the key pressed (e.g., "space", "a", "return")
-- isrepeat: true if this is a key repeat event
if key == "escape" then
love.event.quit()
end
end
function love.keyreleased(key)
-- Called when key is released
endPolling vs Events:
-- Polling: check every frame (good for continuous actions)
function love.update(dt)
if love.keyboard.isDown("left") then
player.x = player.x - player.speed * dt
end
end
-- Events: fire once per press (good for discrete actions)
function love.keypressed(key)
if key == "space" then
player:jump() -- Jump once per press
end
endMouse
function love.mousepressed(x, y, button)
-- button: 1 = left, 2 = right, 3 = middle
if button == 1 then
player:shoot(x, y)
end
end
function love.mousereleased(x, y, button)
end
function love.mousemoved(x, y, dx, dy)
-- dx, dy: movement since last call
end
function love.wheelmoved(x, y)
-- y > 0: scroll up, y < 0: scroll down
endTouch (Mobile)
function love.touchpressed(id, x, y, dx, dy, pressure)
-- id: unique identifier for this finger (multitouch)
end
function love.touchmoved(id, x, y, dx, dy, pressure)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
endWindow Callbacks
function love.resize(w, h)
-- Called when window is resized
screenW, screenH = w, h
repositionUI()
end
function love.focus(focused)
-- focused: true when window gains focus, false when loses
if not focused then
pauseGame()
end
end
function love.visible(visible)
-- visible: true when window is shown, false when hidden
end
function love.quit()
-- Return true to abort quit
saveGame()
return false -- Allow quit
endError Handling
function love.errorhandler(msg)
-- Custom error screen
-- Default shows blue screen with error message
endThe love.run Function
For advanced control, you can override the main loop:
function love.run()
if love.load then love.load() end
local dt = 0
return function()
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
return a or 0
end
love.handlers[name](a, b, c, d, e, f)
end
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
dt = love.timer.step()
end
endModule System
Love2D is organized into modules, each with a specific responsibility:
| Module | Purpose |
|---|---|
love.audio | Sound playback and recording |
love.data | Data transformation (compression, encoding) |
love.event | Event queue management |
love.filesystem | File read/write operations |
love.font | Font rasterization |
love.graphics | All drawing operations |
love.image | Image decoding |
love.joystick | Gamepad/joystick input |
love.keyboard | Keyboard input |
love.math | Math utilities (noise, random, shapes) |
love.mouse | Mouse input |
love.physics | Box2D physics engine |
love.sound | Sound decoding |
love.system | System information |
love.thread | Threading support |
love.timer | Timing functions |
love.touch | Touch screen input |
love.video | Video playback |
love.window | Window management |
Disabling Modules
Disable unused modules in conf.lua for faster startup:
function love.conf(t)
t.modules.joystick = false
t.modules.physics = false
t.modules.video = false
endLua Module Pattern
Organize code with Lua modules:
-- player.lua
local Player = {}
Player.__index = Player
function Player:new(x, y)
return setmetatable({x = x, y = y}, Player)
end
function Player:update(dt) end
function Player:draw() end
return Player-- main.lua
local Player = require("player")
function love.load()
player = Player:new(100, 100)
endGraphics & Drawing
Images, shapes, colors, transforms, and screen adaptation.
Loading Images
function love.load()
-- Load from project directory
playerImage = love.graphics.newImage("player.png")
-- Load from subdirectory
tilesheet = love.graphics.newImage("assets/tiles.png")
endSupported formats: PNG (recommended), JPEG, GIF, BMP, TGA
PNG is preferred because it's lossless and supports transparency.
Drawing Images
Basic Drawing
love.graphics.draw(image, x, y)Full Signature
love.graphics.draw(
image, -- Image to draw
x, y, -- Position
r, -- Rotation (radians)
sx, sy, -- Scale (1 = normal, 2 = double, -1 = flip)
ox, oy, -- Origin offset (rotation/scale pivot)
kx, ky -- Shear
)Examples
-- Draw at position
love.graphics.draw(img, 100, 50)
-- Draw rotated 45 degrees
love.graphics.draw(img, 100, 50, math.rad(45))
-- Draw scaled 2x
love.graphics.draw(img, 100, 50, 0, 2, 2)
-- Draw flipped horizontally
love.graphics.draw(img, 100, 50, 0, -1, 1)
-- Draw centered and rotated (origin at center)
local w, h = img:getDimensions()
love.graphics.draw(img, 100, 50, math.rad(45), 1, 1, w/2, h/2)Image Properties
local width = image:getWidth()
local height = image:getHeight()
local w, h = image:getDimensions()Colors
Colors use values from 0 to 1 (not 0 to 255).
-- Set drawing color (RGBA)
love.graphics.setColor(1, 0, 0, 1) -- Red, fully opaque
love.graphics.setColor(0, 0.5, 1, 0.5) -- Blue, 50% transparent
-- Reset to white (required before drawing images normally)
love.graphics.setColor(1, 1, 1, 1)
-- Background color (set once)
love.graphics.setBackgroundColor(0.1, 0.1, 0.2)Converting from 0-255:
local r, g, b = 255, 128, 64
love.graphics.setColor(r/255, g/255, b/255)Drawing Shapes
Rectangles
-- Filled rectangle
love.graphics.rectangle("fill", x, y, width, height)
-- Outline rectangle
love.graphics.rectangle("line", x, y, width, height)
-- Rounded corners
love.graphics.rectangle("fill", x, y, w, h, rx, ry)Circles
-- Filled circle
love.graphics.circle("fill", x, y, radius)
-- Circle outline
love.graphics.circle("line", x, y, radius)
-- Segments (smoothness, default 36)
love.graphics.circle("fill", x, y, radius, 64)Ellipses
love.graphics.ellipse("fill", x, y, radiusX, radiusY)Lines
love.graphics.line(x1, y1, x2, y2)
love.graphics.line(x1, y1, x2, y2, x3, y3, ...) -- Multiple pointsPolygons
-- Vertices as separate arguments
love.graphics.polygon("fill", x1, y1, x2, y2, x3, y3, ...)
-- Vertices as table
local vertices = {100, 100, 200, 100, 150, 200}
love.graphics.polygon("fill", vertices)Text
-- Basic text
love.graphics.print("Hello World", x, y)
-- With rotation/scale
love.graphics.print("Rotated", x, y, rotation, scaleX, scaleY)
-- Formatted text (wrapping)
love.graphics.printf("Long text here", x, y, limit, align)
-- align: "left", "center", "right", "justify"Custom Fonts
function love.load()
-- Load font
myFont = love.graphics.newFont("font.ttf", 24)
end
function love.draw()
love.graphics.setFont(myFont)
love.graphics.print("Custom font", 10, 10)
endTransforms
Basic Transforms
function love.draw()
love.graphics.push() -- Save current state
love.graphics.translate(100, 100) -- Move origin
love.graphics.rotate(math.rad(45)) -- Rotate
love.graphics.scale(2, 2) -- Scale
-- Draw at transformed position
love.graphics.rectangle("fill", 0, 0, 50, 50)
love.graphics.pop() -- Restore state
endCamera Pattern
local camera = {x = 0, y = 0, scale = 1, rotation = 0}
function love.draw()
love.graphics.push()
-- Apply camera transform
love.graphics.translate(screenW/2, screenH/2)
love.graphics.rotate(camera.rotation)
love.graphics.scale(camera.scale)
love.graphics.translate(-camera.x, -camera.y)
-- Draw world
drawWorld()
love.graphics.pop()
-- Draw UI (unaffected by camera)
drawUI()
endScreen Adaptation
Get Screen Dimensions
local w, h = love.graphics.getDimensions()Handle Resize
local screenW, screenH
function love.load()
screenW, screenH = love.graphics.getDimensions()
end
function love.resize(w, h)
screenW, screenH = w, h
recalculateLayout()
endPositioning Strategies
Percentage-based:
local buttonX = screenW * 0.5 -- Center horizontally
local buttonY = screenH * 0.9 -- Near bottomAnchor-based:
local margin = 20
local rightEdge = screenW - margin
local bottomEdge = screenH - marginLetterboxing (maintain aspect ratio):
local gameWidth, gameHeight = 800, 600
local scaleX = screenW / gameWidth
local scaleY = screenH / gameHeight
local scale = math.min(scaleX, scaleY)
local offsetX = (screenW - gameWidth * scale) / 2
local offsetY = (screenH - gameHeight * scale) / 2
function love.draw()
love.graphics.push()
love.graphics.translate(offsetX, offsetY)
love.graphics.scale(scale)
-- Draw game at 800x600 virtual resolution
drawGame()
love.graphics.pop()
endLine Width and Style
love.graphics.setLineWidth(3)
love.graphics.setLineStyle("smooth") -- or "rough"
love.graphics.setLineJoin("miter") -- "miter", "bevel", "none"Blend Modes
love.graphics.setBlendMode("alpha") -- Default
love.graphics.setBlendMode("add") -- Additive (glow effects)
love.graphics.setBlendMode("multiply") -- Multiply
love.graphics.setBlendMode("replace") -- No blendingCanvases (Render Targets)
Draw to an off-screen buffer:
function love.load()
canvas = love.graphics.newCanvas(800, 600)
end
function love.draw()
-- Draw to canvas
love.graphics.setCanvas(canvas)
love.graphics.clear()
drawScene()
love.graphics.setCanvas() -- Back to screen
-- Draw canvas to screen (can apply effects)
love.graphics.draw(canvas)
endStencils
Mask drawing to specific areas:
function love.draw()
-- Define stencil shape
love.graphics.stencil(function()
love.graphics.circle("fill", 400, 300, 100)
end, "replace", 1)
-- Only draw where stencil value is 1
love.graphics.setStencilTest("greater", 0)
drawScene()
love.graphics.setStencilTest()
endiOS Development Overview
Build and deploy Love2D games to iOS devices.
Philosophy: Mobile-First Game Development
Love2D was born on desktop, but mobile is where players are. The challenge isn't just "making it run on iOS"—it's rethinking the game for touch.
Before building for iOS, ask:
- How will players interact without a keyboard?
- What screen sizes and orientations should be supported?
- Is the game's pacing appropriate for mobile sessions?
- What gestures feel natural for this game's actions?
Core principles:
1. Touch is not a keyboard substitute: Design touch controls that feel native, not bolted-on. A virtual d-pad is a last resort, not a first choice.
2. Screen size is a variable, not a constant: Hard-coded coordinates break on different devices. Think in percentages and relative positions.
3. The build pipeline is fragile: Xcode projects, code signing, and bundle resources have many failure points. Understand the system, don't just copy commands.
4. Iterate on device early: The simulator lies. Test on real hardware as soon as possible.
Development Workflow
Desktop Development First
Develop and test on desktop before touching iOS:
# macOS: Love2D isn't in PATH by default
/Applications/love.app/Contents/MacOS/love /path/to/game
# Or create an alias in ~/.zshrc
alias love="/Applications/love.app/Contents/MacOS/love"Project Structure
my-game/
├── conf.lua # Window size, Love2D version
├── main.lua # Entry point
├── touch.lua # Mobile touch controls (optional on desktop)
└── [game modules] # Player, enemies, etc.iOS Build Pipeline
See setup.md for detailed setup steps.
Quick overview: 1. Download Love2D iOS source + Apple libraries 2. Copy libraries to Xcode project 3. Create game.love (zip of Lua files) 4. Add game.love to Xcode bundle resources 5. Configure signing and deploy
Update Workflow
Every code change requires rebuilding:
# From game directory
rm -f game.love
zip -9 -r game.love *.lua [assets/]
cp game.love /path/to/xcode/ios/
# Then build in Xcode (Cmd+R)Touch Control Patterns
See touch-controls.md for implementation details.
Choosing the Right Pattern
| Game Type | Recommended Control |
|---|---|
| Platformer | Virtual joystick + action buttons |
| Puzzle | Direct touch/drag on game objects |
| Endless runner | Tap/swipe gestures |
| Turn-based | Tap to select, tap to confirm |
| Twin-stick | Dual virtual joysticks |
Touch Event Basics
function love.touchpressed(id, x, y, dx, dy, pressure)
-- id: unique per finger (for multitouch)
-- x, y: screen coordinates
end
function love.touchmoved(id, x, y, dx, dy, pressure)
-- Track finger movement
end
function love.touchreleased(id, x, y, dx, dy, pressure)
-- Clean up touch state
endPlatform Detection
local function isMobile()
local os = love.system.getOS()
return os == "iOS" or os == "Android"
end
-- Use this to conditionally show touch controls
if isMobile() then
touchControls = require("touch")
endScreen Adaptation
Dynamic Sizing
Never hard-code 800x600. Always query dimensions:
local screenW, screenH
function love.load()
screenW, screenH = love.graphics.getDimensions()
end
function love.resize(w, h)
screenW, screenH = w, h
-- Reposition UI, regenerate layouts
endPositioning Strategies
Percentage-based:
local buttonX = screenW * 0.85 -- 85% from left
local buttonY = screenH * 0.9 -- 90% from topAnchor-based:
local margin = 20
local rightEdge = screenW - margin
local bottomEdge = screenH - marginAspect-ratio aware:
local targetAspect = 16/9
local currentAspect = screenW / screenH
-- Add letterboxing or adjust game areaAnti-Patterns to Avoid
| Don't | Why | Do Instead |
|---|---|---|
| Hard-code coordinates | Breaks on different screens | Use percentages or anchors |
| Ignore "No-game screen" | game.love wasn't bundled | Verify in bundle resources |
| Test only on simulator | Different performance/touch | Deploy to real device early |
| Use giant virtual joysticks | Obscures gameplay | Semi-transparent, 60-80px radius |
| Copy Xcode changes blindly | Won't know how to fix issues | Understand project.pbxproj |
| Forget to rebuild game.love | Testing old code | Script the rebuild process |
Common Issues and Solutions
Deployment Target Errors
Error: IPHONEOS_DEPLOYMENT_TARGET is set to 8.0, but range is 12.0 to X.X
Fix:
find . -name "*.pbxproj" -exec sed -i '' \
's/IPHONEOS_DEPLOYMENT_TARGET = 8.0/IPHONEOS_DEPLOYMENT_TARGET = 15.0/g' {} \;"No-game screen" on Device
Cause: game.love not in bundle resources.
Fix: Add game.love to Xcode project: 1. Right-click ios folder → Add Files 2. Select game.love 3. Ensure "Add to targets: love-ios" is checked
If that fails, see xcode-project.md for manual pbxproj editing.
Signing Errors
Fix: In Xcode: 1. Select love-ios target 2. Signing & Capabilities → Select your Team 3. Change Bundle Identifier to something unique
Touch Not Responding
Causes:
- Not implementing touch callbacks
- Touch area too small (minimum 44x44 points recommended)
- Touch being consumed by wrong element
File Locations Reference
| Purpose | Path |
|---|---|
| Xcode project | love-X.X-ios-source/platform/xcode/love.xcodeproj |
| iOS libraries | love-X.X-ios-source/platform/xcode/ios/libraries/ |
| game.love destination | love-X.X-ios-source/platform/xcode/ios/game.love |
| Project config | love.xcodeproj/project.pbxproj |
Remember
Love2D makes game development joyful. iOS deployment adds friction, but understanding the pipeline—not just following steps—makes you resilient when things break.
The goal isn't "run on iOS." The goal is "feel great on iOS."
Touch controls that feel native, layouts that adapt gracefully, and a build process you understand—that's the standard.
iOS Build Setup
Complete setup guide for building Love2D games on iOS.
Prerequisites
- macOS with Xcode installed (16+ recommended)
- Apple Developer Account (free works for personal device testing)
- iOS device connected via USB
- Love2D desktop app for testing
Step 1: Download Required Files
From https://love2d.org/ or https://github.com/love2d/love/releases:
1. `love-X.X-ios-source.zip` - Xcode project and source 2. `love-X.X-apple-libraries.zip` - Required iOS libraries
Step 2: Extract and Setup Libraries
# Extract both archives
unzip love-11.5-ios-source.zip
unzip love-11.5-apple-libraries.zip
# Copy iOS libraries to correct location
cp -r love-apple-dependencies/iOS/libraries/* \
love-11.5-ios-source/platform/xcode/ios/libraries/Verify libraries installed:
ls love-11.5-ios-source/platform/xcode/ios/libraries/
# Should see: freetype, lua, openal-soft, etc.Step 3: Fix Deployment Target
Modern Xcode requires iOS 12.0+ deployment target. Love2D ships with 8.0.
cd love-11.5-ios-source/platform/xcode
# Update all project files
find . -name "*.pbxproj" -exec sed -i '' \
's/IPHONEOS_DEPLOYMENT_TARGET = 8.0/IPHONEOS_DEPLOYMENT_TARGET = 15.0/g' {} \;
# Also fix any 12.0 targets if needed
find . -name "*.pbxproj" -exec sed -i '' \
's/IPHONEOS_DEPLOYMENT_TARGET = 12.0/IPHONEOS_DEPLOYMENT_TARGET = 15.0/g' {} \;Why 15.0? Balances broad device compatibility with modern iOS features.
Step 4: Create game.love
cd /path/to/your/game
# Bundle all Lua files
zip -9 -r game.love *.lua
# Include assets if you have them
zip -9 -r game.love *.lua assets/ sounds/ images/Step 5: Add game.love to iOS Project
cp game.love love-11.5-ios-source/platform/xcode/ios/Important: Copying the file is not enough. You must add it to Xcode's bundle resources.
Method A: Xcode UI (Preferred)
1. Open love.xcodeproj in Xcode 2. In project navigator, right-click the ios folder 3. Select "Add Files to 'love'..." 4. Navigate to and select game.love 5. Check "Add to targets: love-ios" 6. Click Add
Method B: Manual pbxproj Edit
If Xcode UI doesn't work, see xcode-project.md for manual editing.
Step 6: Configure Signing
1. Select love-ios target (not love-macosx) 2. Go to Signing & Capabilities tab 3. Select your Team from dropdown 4. Change Bundle Identifier to something unique:
- Example:
com.yourname.yourgame - Must be globally unique
Step 7: Build and Deploy
1. Connect iOS device via USB 2. Select your device as run destination (not simulator) 3. Press ⌘R or Product → Run 4. Trust the developer certificate on device if prompted:
- Settings → General → VPN & Device Management
Troubleshooting
"No-game screen"
game.love not bundled. Verify in Xcode: 1. Select love-ios target 2. Build Phases → Copy Bundle Resources 3. Confirm game.love is listed
Build succeeds but app crashes
Check Console.app on Mac for crash logs. Common causes:
- Missing assets referenced in Lua
- Syntax errors in Lua files
- Library incompatibilities
"Signing requires a development team"
Select your Apple ID team in Signing & Capabilities.
Simulator vs Device
The simulator is x86/ARM translated and doesn't perfectly match iOS behavior. Always verify on real hardware before considering the build "done."
Automation Script
For repeated builds, create a script:
#!/bin/bash
# build-ios.sh
GAME_DIR="/path/to/your/game"
XCODE_DIR="/path/to/love-11.5-ios-source/platform/xcode"
# Build game.love
cd "$GAME_DIR"
rm -f game.love
zip -9 -r game.love *.lua assets/
# Copy to Xcode project
cp game.love "$XCODE_DIR/ios/"
echo "game.love updated. Open Xcode and build (Cmd+R)"Make it executable: chmod +x build-ios.sh
Touch Controls Implementation
Patterns and code for implementing touch controls in Love2D iOS games.
Touch Event System
Love2D provides three touch callbacks:
function love.touchpressed(id, x, y, dx, dy, pressure)
-- Finger touched screen
-- id: unique identifier for this finger (multitouch support)
-- x, y: position in screen coordinates
-- pressure: 0-1 on devices that support it
end
function love.touchmoved(id, x, y, dx, dy, pressure)
-- Finger moved while touching
-- dx, dy: delta from last position
end
function love.touchreleased(id, x, y, dx, dy, pressure)
-- Finger lifted from screen
endImportant: Track touch IDs for multitouch. Don't assume single touch.
Virtual Joystick Implementation
Basic Structure
local Touch = {}
Touch.__index = Touch
function Touch:new(screenW, screenH)
local self = setmetatable({}, Touch)
self.screenW = screenW
self.screenH = screenH
-- Joystick state
self.joystick = {
active = false,
touchId = nil,
baseX = 0,
baseY = 0,
knobX = 0,
knobY = 0,
radius = 60,
knobRadius = 25,
deadzone = 0.15
}
-- Output values (-1 to 1)
self.moveX = 0
self.moveY = 0
return self
endFloating Joystick Pattern
Joystick appears where you touch (more intuitive than fixed position):
function Touch:touchpressed(id, x, y)
local js = self.joystick
-- Left third of screen, bottom half = joystick zone
if x < self.screenW / 3 and y > self.screenH / 2 then
js.active = true
js.touchId = id
js.baseX = x
js.baseY = y
js.knobX = x
js.knobY = y
end
end
function Touch:touchmoved(id, x, y)
local js = self.joystick
if js.active and js.touchId == id then
local dx = x - js.baseX
local dy = y - js.baseY
local dist = math.sqrt(dx * dx + dy * dy)
-- Clamp knob to radius
if dist > js.radius then
dx = dx / dist * js.radius
dy = dy / dist * js.radius
dist = js.radius
end
js.knobX = js.baseX + dx
js.knobY = js.baseY + dy
-- Normalize to -1 to 1 with deadzone
local normalizedDist = dist / js.radius
if normalizedDist < js.deadzone then
self.moveX = 0
self.moveY = 0
else
-- Remap deadzone to full range
local scale = (normalizedDist - js.deadzone) / (1 - js.deadzone)
self.moveX = (dx / dist) * scale
self.moveY = (dy / dist) * scale
end
end
end
function Touch:touchreleased(id)
local js = self.joystick
if js.touchId == id then
js.active = false
js.touchId = nil
self.moveX = 0
self.moveY = 0
end
endDrawing the Joystick
function Touch:draw()
local js = self.joystick
if js.active then
-- Base circle (semi-transparent)
love.graphics.setColor(1, 1, 1, 0.3)
love.graphics.circle("fill", js.baseX, js.baseY, js.radius)
love.graphics.setColor(1, 1, 1, 0.5)
love.graphics.circle("line", js.baseX, js.baseY, js.radius)
-- Knob
love.graphics.setColor(0.2, 0.6, 1, 0.8)
love.graphics.circle("fill", js.knobX, js.knobY, js.knobRadius)
end
love.graphics.setColor(1, 1, 1, 1) -- Reset color
endAction Buttons
Button Structure
function Touch:new(screenW, screenH)
-- ... joystick setup ...
-- Action buttons (right side of screen)
local btnSize = 70
local margin = 30
self.fireButton = {
x = screenW - margin - btnSize,
y = screenH - margin - btnSize,
radius = btnSize / 2,
active = false,
touchId = nil,
label = "FIRE",
color = {0.8, 0.2, 0.2}
}
self.jumpButton = {
x = screenW - margin - btnSize,
y = screenH - margin - btnSize * 2 - 20,
radius = btnSize / 2,
active = false,
touchId = nil,
label = "JUMP",
color = {0.2, 0.7, 0.3}
}
self.shooting = false
self.jumped = false
endButton Touch Handling
local function pointInCircle(px, py, cx, cy, r)
local dx = px - cx
local dy = py - cy
return (dx * dx + dy * dy) <= (r * r)
end
function Touch:touchpressed(id, x, y)
-- Check fire button
local fb = self.fireButton
if pointInCircle(x, y, fb.x, fb.y, fb.radius * 1.2) then
fb.active = true
fb.touchId = id
self.shooting = true
return
end
-- Check jump button
local jb = self.jumpButton
if pointInCircle(x, y, jb.x, jb.y, jb.radius * 1.2) then
jb.active = true
jb.touchId = id
self.jumped = true -- Single press, not held
return
end
-- ... joystick handling ...
end
function Touch:touchreleased(id)
if self.fireButton.touchId == id then
self.fireButton.active = false
self.fireButton.touchId = nil
self.shooting = false
end
if self.jumpButton.touchId == id then
self.jumpButton.active = false
self.jumpButton.touchId = nil
end
-- ... joystick handling ...
endDrawing Buttons
function Touch:drawButton(btn)
-- Button background
if btn.active then
love.graphics.setColor(btn.color[1], btn.color[2], btn.color[3], 0.9)
else
love.graphics.setColor(btn.color[1], btn.color[2], btn.color[3], 0.5)
end
love.graphics.circle("fill", btn.x, btn.y, btn.radius)
-- Button border
love.graphics.setColor(1, 1, 1, 0.7)
love.graphics.circle("line", btn.x, btn.y, btn.radius)
-- Label
love.graphics.setColor(1, 1, 1, 0.9)
local font = love.graphics.getFont()
local textW = font:getWidth(btn.label)
local textH = font:getHeight()
love.graphics.print(btn.label, btn.x - textW/2, btn.y - textH/2)
end
function Touch:draw()
self:drawButton(self.fireButton)
self:drawButton(self.jumpButton)
-- ... joystick drawing ...
endIntegrating with Game Logic
In main.lua
local Touch = require("touch")
local touchControls
function love.load()
local w, h = love.graphics.getDimensions()
if love.system.getOS() == "iOS" or love.system.getOS() == "Android" then
touchControls = Touch:new(w, h)
end
end
function love.update(dt)
if touchControls then
-- Pass touch input to player
player:setTouchInput(
touchControls.moveX,
touchControls.moveY,
touchControls.shooting,
touchControls.jumped
)
-- Clear single-press flags
touchControls.jumped = false
end
end
function love.draw()
-- Draw game...
-- Draw touch controls on top
if touchControls then
touchControls:draw()
end
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if touchControls then
touchControls:touchpressed(id, x, y)
end
end
function love.touchmoved(id, x, y, dx, dy, pressure)
if touchControls then
touchControls:touchmoved(id, x, y)
end
end
function love.touchreleased(id, x, y, dx, dy, pressure)
if touchControls then
touchControls:touchreleased(id, x, y)
end
endIn player.lua
function Player:setTouchInput(moveX, moveY, shooting, jump)
self.touchMoveX = moveX or 0
self.touchMoveY = moveY or 0
self.touchShooting = shooting or false
if jump then
self.touchJump = true
end
end
function Player:update(dt)
-- Combine keyboard and touch input
local moveX = 0
if love.keyboard.isDown("left", "a") then moveX = -1 end
if love.keyboard.isDown("right", "d") then moveX = 1 end
-- Touch overrides if active
if math.abs(self.touchMoveX) > 0.1 then
moveX = self.touchMoveX
end
self.x = self.x + moveX * self.speed * dt
-- Jumping
if self.onGround and (love.keyboard.isDown("space", "w", "up") or self.touchJump) then
self.vy = self.jumpForce
self.onGround = false
self.touchJump = false
end
-- Shooting
local shooting = love.mouse.isDown(1) or self.touchShooting
if shooting then
self:shoot()
end
endGesture Patterns
Tap Detection
local tapState = {
startTime = 0,
startX = 0,
startY = 0,
maxTapDuration = 0.3, -- seconds
maxTapDistance = 20 -- pixels
}
function love.touchpressed(id, x, y)
tapState.startTime = love.timer.getTime()
tapState.startX = x
tapState.startY = y
end
function love.touchreleased(id, x, y)
local duration = love.timer.getTime() - tapState.startTime
local dx = x - tapState.startX
local dy = y - tapState.startY
local distance = math.sqrt(dx*dx + dy*dy)
if duration < tapState.maxTapDuration and distance < tapState.maxTapDistance then
onTap(x, y) -- Handle tap
end
endSwipe Detection
local swipeState = {
startX = 0,
startY = 0,
minSwipeDistance = 50
}
function love.touchpressed(id, x, y)
swipeState.startX = x
swipeState.startY = y
end
function love.touchreleased(id, x, y)
local dx = x - swipeState.startX
local dy = y - swipeState.startY
local distance = math.sqrt(dx*dx + dy*dy)
if distance > swipeState.minSwipeDistance then
-- Determine direction
if math.abs(dx) > math.abs(dy) then
if dx > 0 then onSwipe("right")
else onSwipe("left") end
else
if dy > 0 then onSwipe("down")
else onSwipe("up") end
end
end
endBest Practices
1. Touch target size: Minimum 44x44 points (Apple HIG recommendation) 2. Deadzone: 10-20% to prevent drift from resting thumb 3. Visual feedback: Show button press states clearly 4. Transparency: Don't obscure gameplay with opaque controls 5. Thumb reach: Place controls where thumbs naturally rest 6. Multitouch: Always track touch IDs, never assume single touch
Xcode Project Structure
Understanding project.pbxproj for manual game.love bundling.
When You Need This
When Xcode's "Add Files" UI doesn't work or you need to automate the process, you'll need to manually edit love.xcodeproj/project.pbxproj.
project.pbxproj Overview
The pbxproj file is a structured text file (OpenStep plist format) containing:
- PBXBuildFile: Files that get compiled or copied
- PBXFileReference: All files known to the project
- PBXGroup: Folder structure in Xcode navigator
- PBXNativeTarget: Build targets (love-ios, love-macosx)
- PBXResourcesBuildPhase: Files copied to app bundle (this is key!)
Adding game.love Manually
You need to add entries to four sections:
1. PBXBuildFile Section
Find /* Begin PBXBuildFile section */ and add:
GAMELOVE000200000000001 /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = GAMELOVE000100000000001 /* game.love */; };This tells Xcode "game.love should be copied as a resource."
2. PBXFileReference Section
Find /* Begin PBXFileReference section */ and add:
GAMELOVE000100000000001 /* game.love */ = {isa = PBXFileReference; lastKnownFileType = file; name = game.love; path = ios/game.love; sourceTree = "<group>"; };This defines the file reference—where the file lives and what it is.
3. PBXGroup (Resources or iOS folder)
Find the group that represents the ios folder. Look for something like:
/* ios */ = {
isa = PBXGroup;
children = (
...existing files...
);Add your file reference to the children array:
children = (
...existing files...,
GAMELOVE000100000000001 /* game.love */,
);4. Copy Bundle Resources Build Phase
Find the /* Copy Bundle Resources */ section for the love-ios target (not love-macosx). Look for:
/* Copy Bundle Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
...existing files...
);Add your build file:
files = (
...existing files...,
GAMELOVE000200000000001 /* game.love in Resources */,
);Important Notes
ID Format
The IDs (like GAMELOVE000200000000001) must be:
- Unique across the entire file
- 24 characters (hexadecimal typically, but any chars work)
- Consistent between PBXBuildFile fileRef and PBXFileReference
Finding the Right Target
There may be multiple "Copy Bundle Resources" sections:
- One for love-ios (iOS target) ✓
- One for love-macosx (macOS target)
Make sure you add to the love-ios target's build phase.
To identify which is which, look for nearby comments or trace the target's buildPhases array.
Trailing Commas
Xcode is forgiving about trailing commas in arrays. Adding a comma after your entry is safe.
Example: Complete Addition
Before:
/* Begin PBXBuildFile section */
...existing entries...
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
...existing entries...
/* End PBXFileReference section */After:
/* Begin PBXBuildFile section */
...existing entries...
GAMELOVE000200000000001 /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = GAMELOVE000100000000001 /* game.love */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
...existing entries...
GAMELOVE000100000000001 /* game.love */ = {isa = PBXFileReference; lastKnownFileType = file; name = game.love; path = ios/game.love; sourceTree = "<group>"; };
/* End PBXFileReference section */Automation Script
#!/bin/bash
# add-game-to-xcode.sh
# Run from xcode project directory
PBXPROJ="love.xcodeproj/project.pbxproj"
# Unique IDs for game.love
FILE_REF="GAMELOVE000100000000001"
BUILD_REF="GAMELOVE000200000000001"
# Check if already added
if grep -q "$FILE_REF" "$PBXPROJ"; then
echo "game.love already in project"
exit 0
fi
# Backup
cp "$PBXPROJ" "$PBXPROJ.backup"
# Add PBXBuildFile entry
sed -i '' "/\/\* Begin PBXBuildFile section \*\//a\\
$BUILD_REF /* game.love in Resources */ = {isa = PBXBuildFile; fileRef = $FILE_REF /* game.love */; };
" "$PBXPROJ"
# Add PBXFileReference entry
sed -i '' "/\/\* Begin PBXFileReference section \*\//a\\
$FILE_REF /* game.love */ = {isa = PBXFileReference; lastKnownFileType = file; name = game.love; path = ios/game.love; sourceTree = \"<group>\"; };
" "$PBXPROJ"
echo "Added game.love references. Manually add to group and build phase if needed."Verifying Success
After editing:
1. Open Xcode—if it complains about project format, you made a syntax error 2. Check love-ios target → Build Phases → Copy Bundle Resources 3. game.love should appear in the list 4. Build and verify game loads on device
Troubleshooting
Xcode won't open project: Syntax error in pbxproj. Restore backup and try again.
game.love in project but not loading: Added to wrong target or wrong build phase. Verify it's in love-ios's "Copy Bundle Resources."
Duplicate symbol errors: You added the same ID twice. Each ID must be unique.
Libraries
Popular community libraries for Love2D game development.
Physics & Collision
bump.lua
Purpose: Simple AABB collision detection and response URL: https://github.com/kikito/bump.lua
local bump = require("lib.bump")
local world = bump.newWorld(64) -- 64px cell size
-- Add objects
world:add(player, player.x, player.y, player.w, player.h)
world:add(wall, wall.x, wall.y, wall.w, wall.h)
-- Move with collision
local actualX, actualY, cols, len = world:move(player, newX, newY)
player.x, player.y = actualX, actualY
-- Handle collisions
for i = 1, len do
local col = cols[i]
if col.other.type == "enemy" then
player:takeDamage()
end
endWindfield
Purpose: Physics wrapper for Box2D with simpler API URL: https://github.com/a327ex/windfield
local wf = require("lib.windfield")
local world = wf.newWorld(0, 512) -- gravity x, y
local player = world:newRectangleCollider(100, 100, 50, 50)
player:setType("dynamic")
local ground = world:newRectangleCollider(0, 550, 800, 50)
ground:setType("static")
function love.update(dt)
world:update(dt)
end
function love.draw()
world:draw()
endAnimation
anim8
Purpose: Animation library for sprite sheets URL: https://github.com/kikito/anim8
local anim8 = require("lib.anim8")
local image = love.graphics.newImage("player.png")
local grid = anim8.newGrid(32, 32, image:getWidth(), image:getHeight())
local walkAnim = anim8.newAnimation(grid('1-4', 1), 0.1)
local jumpAnim = anim8.newAnimation(grid('1-3', 2), 0.15)
function love.update(dt)
walkAnim:update(dt)
end
function love.draw()
walkAnim:draw(image, player.x, player.y)
endCamera
gamera
Purpose: Camera system with bounds, zoom, rotation URL: https://github.com/kikito/gamera
local gamera = require("lib.gamera")
-- World bounds
local cam = gamera.new(0, 0, 2000, 2000)
function love.update(dt)
cam:setPosition(player.x, player.y)
end
function love.draw()
cam:draw(function(l, t, w, h)
-- Draw world (l,t,w,h = visible area)
drawMap()
drawEntities()
end)
-- Draw UI outside camera
drawUI()
end
-- Zoom
cam:setScale(2)
-- Get world coordinates from screen
local worldX, worldY = cam:toWorld(mouseX, mouseY)STALKER-X
Purpose: Camera with smooth follow, screen shake, deadzone URL: https://github.com/a327ex/STALKER-X
local Camera = require("lib.stalker-x")
local camera = Camera()
function love.update(dt)
camera:update(dt)
camera:follow(player.x, player.y)
end
function love.draw()
camera:attach()
drawWorld()
camera:detach()
drawUI()
end
-- Screen shake
camera:shake(8, 0.5, 60) -- intensity, duration, frequencyGUI
SUIT
Purpose: Immediate-mode GUI URL: https://github.com/vrld/suit
local suit = require("lib.suit")
local input = {text = ""}
function love.update(dt)
suit.layout:reset(100, 100)
if suit.Button("Click me", suit.layout:row(200, 30)).hit then
print("Clicked!")
end
suit.Input(input, suit.layout:row())
if suit.Checkbox(checked, "Enable", suit.layout:row()).changed then
checked = not checked
end
end
function love.draw()
suit.draw()
endSlab
Purpose: Immediate-mode GUI inspired by Dear ImGui URL: https://github.com/flamendless/Slab
local Slab = require("lib.Slab")
function love.load()
Slab.Initialize()
end
function love.update(dt)
Slab.Update(dt)
Slab.BeginWindow("Debug", {Title = "Debug Panel"})
Slab.Text("FPS: " .. love.timer.getFPS())
if Slab.Button("Reset") then
resetGame()
end
Slab.EndWindow()
end
function love.draw()
drawGame()
Slab.Draw()
endState Management
hump.gamestate
Purpose: Game state management URL: https://github.com/vrld/hump
local Gamestate = require("lib.hump.gamestate")
local menu = {}
local game = {}
function menu:enter()
-- Called when entering state
end
function menu:update(dt)
end
function menu:draw()
love.graphics.print("Press SPACE to start", 300, 300)
end
function menu:keypressed(key)
if key == "space" then
Gamestate.switch(game)
end
end
function game:enter()
player = Player:new(100, 100)
end
function game:update(dt)
player:update(dt)
end
function game:draw()
player:draw()
end
function love.load()
Gamestate.registerEvents()
Gamestate.switch(menu)
endTiled Map Support
STI (Simple Tiled Implementation)
Purpose: Load and render Tiled maps URL: https://github.com/karai17/Simple-Tiled-Implementation
local sti = require("lib.sti")
local map = sti("maps/level1.lua")
function love.update(dt)
map:update(dt)
end
function love.draw()
map:draw()
-- Draw specific layer
map:drawLayer(map.layers["foreground"])
end
-- Collision with bump
local sti = require("lib.sti")
local bump = require("lib.bump")
local map = sti("maps/level1.lua", {"bump"})
local world = bump.newWorld()
map:bump_init(world)Input
baton
Purpose: Input library with controller support URL: https://github.com/tesselode/baton
local baton = require("lib.baton")
local input = baton.new({
controls = {
left = {"key:left", "key:a", "axis:leftx-"},
right = {"key:right", "key:d", "axis:leftx+"},
jump = {"key:space", "button:a"},
shoot = {"key:x", "button:b", "mouse:1"},
},
pairs = {
move = {"left", "right", "up", "down"}
},
joystick = love.joystick.getJoysticks()[1]
})
function love.update(dt)
input:update()
local moveX, moveY = input:get("move")
player.x = player.x + moveX * player.speed * dt
if input:pressed("jump") then
player:jump()
end
if input:down("shoot") then
player:shoot()
end
endTweening
flux
Purpose: Tweening library URL: https://github.com/rxi/flux
local flux = require("lib.flux")
-- Tween object properties
flux.to(player, 1, {x = 400, y = 300})
:ease("quadout")
:oncomplete(function() print("Done!") end)
-- Chain tweens
flux.to(obj, 0.5, {alpha = 0})
:after(obj, 0.5, {alpha = 1})
function love.update(dt)
flux.update(dt)
endtween.lua
Purpose: Simple tweening URL: https://github.com/kikito/tween.lua
local tween = require("lib.tween")
local target = {x = 100, y = 100}
local myTween = tween.new(2, target, {x = 400, y = 300}, "outQuad")
function love.update(dt)
local complete = myTween:update(dt)
if complete then
-- Tween finished
end
endUtilities
lume
Purpose: Collection of utility functions URL: https://github.com/rxi/lume
local lume = require("lib.lume")
-- Math
lume.clamp(x, min, max)
lume.lerp(a, b, t)
lume.round(x)
lume.sign(x)
lume.distance(x1, y1, x2, y2)
lume.angle(x1, y1, x2, y2)
-- Tables
lume.randomchoice({"a", "b", "c"})
lume.shuffle(t)
lume.filter(t, fn)
lume.map(t, fn)
lume.reduce(t, fn, init)
lume.find(t, value)
lume.merge(t1, t2)
-- Strings
lume.split("a,b,c", ",")
lume.trim(" hello ")
lume.format("{name} has {n} apples", {name="John", n=5})
-- Serialization
local str = lume.serialize(t)
local t = lume.deserialize(str)classic
Purpose: Tiny class library URL: https://github.com/rxi/classic
local Object = require("lib.classic")
local Entity = Object:extend()
function Entity:new(x, y)
self.x = x
self.y = y
end
function Entity:update(dt) end
function Entity:draw() end
-- Inheritance
local Player = Entity:extend()
function Player:new(x, y)
Player.super.new(self, x, y)
self.speed = 200
end
function Player:update(dt)
-- Player-specific logic
endInstalling Libraries
1. Download the library 2. Place in lib/ folder 3. Require in your code
-- For single-file libraries
local bump = require("lib.bump")
-- For folder-based libraries
local sti = require("lib.sti")Compatibility Notes
- Check library compatibility with your Love2D version
- Some libraries require Love2D 11.x+
- Read the library's README for setup instructions
- Some libraries have additional dependencies
Project Structure
File organization, configuration, and distribution.
Minimal Project
The absolute minimum Love2D project:
my-game/
└── main.lua-- main.lua
function love.draw()
love.graphics.print("Hello World", 400, 300)
endRecommended Structure
For anything beyond a prototype:
my-game/
├── main.lua # Entry point
├── conf.lua # Configuration
├── assets/
│ ├── images/ # PNG, JPG files
│ ├── sounds/ # WAV, OGG files
│ ├── fonts/ # TTF, OTF files
│ └── maps/ # Level data
├── src/
│ ├── player.lua # Player class
│ ├── enemy.lua # Enemy class
│ ├── level.lua # Level management
│ └── ui.lua # UI components
├── lib/ # Third-party libraries
│ ├── bump.lua
│ └── anim8.lua
└── states/ # Game states (optional)
├── menu.lua
├── game.lua
└── gameover.luaconf.lua
Configuration runs before the game starts. Set window properties, enable/disable modules.
Basic Configuration
function love.conf(t)
t.window.title = "My Game"
t.window.width = 800
t.window.height = 600
endFull Configuration
function love.conf(t)
-- Identity (used for save directory)
t.identity = "mygame"
-- Love2D version
t.version = "11.5"
-- Console (Windows only)
t.console = false
-- Window settings
t.window.title = "My Game"
t.window.icon = nil -- Path to icon
t.window.width = 800
t.window.height = 600
t.window.borderless = false
t.window.resizable = false
t.window.minwidth = 400
t.window.minheight = 300
t.window.fullscreen = false
t.window.fullscreentype = "desktop" -- "desktop" or "exclusive"
t.window.vsync = 1 -- 1 = on, 0 = off, -1 = adaptive
t.window.msaa = 0 -- Anti-aliasing samples
t.window.depth = nil
t.window.stencil = nil
t.window.display = 1 -- Monitor index
t.window.highdpi = false -- Retina/HiDPI support
t.window.usedpiscale = true
-- Module toggles (disable unused for faster startup)
t.modules.audio = true
t.modules.data = true
t.modules.event = true
t.modules.font = true
t.modules.graphics = true
t.modules.image = true
t.modules.joystick = false -- Disable if not using gamepads
t.modules.keyboard = true
t.modules.math = true
t.modules.mouse = true
t.modules.physics = false -- Disable if not using Box2D
t.modules.sound = true
t.modules.system = true
t.modules.thread = true
t.modules.timer = true
t.modules.touch = true -- Enable for mobile
t.modules.video = false -- Disable if not playing video
t.modules.window = true
endmain.lua Patterns
Simple Entry Point
function love.load()
-- Initialize
end
function love.update(dt)
-- Update logic
end
function love.draw()
-- Render
endWith State Management
local states = {
menu = require("states.menu"),
game = require("states.game"),
gameover = require("states.gameover")
}
local currentState = "menu"
function love.load()
for _, state in pairs(states) do
if state.load then state.load() end
end
end
function love.update(dt)
local state = states[currentState]
if state.update then state.update(dt) end
end
function love.draw()
local state = states[currentState]
if state.draw then state.draw() end
end
function love.keypressed(key)
local state = states[currentState]
if state.keypressed then state.keypressed(key) end
end
function switchState(newState)
local oldState = states[currentState]
if oldState.exit then oldState.exit() end
currentState = newState
local state = states[currentState]
if state.enter then state.enter() end
endWith Globals Module
-- globals.lua
return {
screenW = 800,
screenH = 600,
debug = true,
player = nil,
score = 0
}
-- main.lua
local G = require("globals")
function love.load()
G.screenW, G.screenH = love.graphics.getDimensions()
G.player = require("src.player"):new(100, 100)
endModule Pattern
Creating a Module
-- src/player.lua
local Player = {}
Player.__index = Player
function Player:new(x, y)
local self = setmetatable({}, Player)
self.x = x
self.y = y
self.speed = 200
self.image = love.graphics.newImage("assets/images/player.png")
return self
end
function Player:update(dt)
-- Movement logic
end
function Player:draw()
love.graphics.draw(self.image, self.x, self.y)
end
return PlayerUsing a Module
local Player = require("src.player")
function love.load()
player = Player:new(400, 300)
endAsset Loading
Centralized Asset Manager
-- assets.lua
local Assets = {
images = {},
sounds = {},
fonts = {}
}
function Assets:loadImage(name, path)
self.images[name] = love.graphics.newImage(path)
end
function Assets:loadSound(name, path, sourceType)
self.sounds[name] = love.audio.newSource(path, sourceType or "static")
end
function Assets:loadFont(name, path, size)
self.fonts[name] = love.graphics.newFont(path, size)
end
function Assets:getImage(name)
return self.images[name]
end
function Assets:getSound(name)
return self.sounds[name]
end
function Assets:getFont(name)
return self.fonts[name]
end
return AssetsUsage
local Assets = require("assets")
function love.load()
Assets:loadImage("player", "assets/images/player.png")
Assets:loadImage("enemy", "assets/images/enemy.png")
Assets:loadSound("jump", "assets/sounds/jump.wav")
Assets:loadFont("main", "assets/fonts/pixel.ttf", 16)
end
function love.draw()
love.graphics.draw(Assets:getImage("player"), 100, 100)
endSave Directory
Love2D has a dedicated save directory for each game:
-- Set identity in conf.lua
t.identity = "mygame"
-- Save directory locations:
-- Windows: C:\Users\user\AppData\Roaming\LOVE\mygame
-- macOS: /Users/user/Library/Application Support/LOVE/mygame
-- Linux: ~/.local/share/love/mygameSaving Data
function saveGame()
local data = {
score = score,
level = currentLevel,
playerX = player.x,
playerY = player.y
}
local serialized = "return " .. serialize(data)
love.filesystem.write("save.lua", serialized)
end
function loadGame()
if love.filesystem.getInfo("save.lua") then
local chunk = love.filesystem.load("save.lua")
local data = chunk()
score = data.score
currentLevel = data.level
player.x = data.playerX
player.y = data.playerY
end
end
-- Simple serializer
function serialize(t)
local parts = {"{"}
for k, v in pairs(t) do
local key = type(k) == "string" and k or "[" .. k .. "]"
local val
if type(v) == "string" then
val = string.format("%q", v)
elseif type(v) == "table" then
val = serialize(v)
else
val = tostring(v)
end
table.insert(parts, key .. "=" .. val .. ",")
end
table.insert(parts, "}")
return table.concat(parts)
endDistribution
Creating a .love File
A .love file is a ZIP containing your game:
cd my-game
zip -9 -r ../my-game.love .Important: main.lua must be at the root of the ZIP.
Running .love Files
# macOS
/Applications/love.app/Contents/MacOS/love my-game.love
# Windows
love.exe my-game.love
# Linux
love my-game.loveCreating Executables
Windows:
# Append .love to love.exe
copy /b love.exe+my-game.love my-game.exemacOS:
# Copy love.app, add game.love to Resources
cp -r /Applications/love.app My-Game.app
cp my-game.love My-Game.app/Contents/Resources/Tools for Distribution
- love-release - Build tool for multiple platforms
- boon - Another build tool
- makelove - Python-based builder
Best Practices
1. Keep main.lua thin: Delegate to modules 2. Use consistent naming: snake_case or camelCase, pick one 3. Separate concerns: Graphics, logic, data in different files 4. Load assets once: In love.load(), not during gameplay 5. Use relative paths: assets/images/player.png, not absolute paths 6. Version control: Use Git, ignore generated files 7. Document dependencies: List required libraries
.gitignore for Love2D
# Build artifacts
*.love
*.exe
*.app/
# OS files
.DS_Store
Thumbs.db
# Editor files
*.swp
*.swo
.vscode/
.idea/
# Temporary files
*.logTiles & Maps
Building tile-based levels with tilesets and map data.
Concept
Tile-based games use a grid of small images (tiles) to create levels.
Benefits:
- Memory efficient: Reuse tile images
- Easy to edit: Change map data, not individual sprites
- Collision-friendly: Grid-based collision is simple
Tilesets
A tileset is one image containing all tile types:
[Grass][Dirt][Stone][Water]
[Tree ][Bush][Rock ][Sand ]Loading a Tileset
function love.load()
tileset = love.graphics.newImage("tileset.png")
tileW, tileH = 32, 32 -- Each tile is 32x32
local imgW, imgH = tileset:getDimensions()
local cols = imgW / tileW
local rows = imgH / tileH
-- Create quads for each tile
tiles = {}
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local id = row * cols + col + 1
tiles[id] = love.graphics.newQuad(
col * tileW, row * tileH,
tileW, tileH,
imgW, imgH
)
end
end
endImage dimensions: Use power-of-two sizes (64, 128, 256, 512) for best compatibility.
Map Data
Store level layout as a 2D array of tile IDs:
local map = {
{1, 1, 1, 1, 1, 1, 1, 1},
{1, 0, 0, 0, 0, 0, 0, 1},
{1, 0, 0, 2, 2, 0, 0, 1},
{1, 0, 0, 0, 0, 0, 0, 1},
{1, 1, 1, 1, 1, 1, 1, 1},
}
-- 0 = empty, 1 = wall, 2 = floorDrawing the Map
function drawMap()
for row = 1, #map do
for col = 1, #map[row] do
local tileId = map[row][col]
if tileId > 0 then
local x = (col - 1) * tileW
local y = (row - 1) * tileH
love.graphics.draw(tileset, tiles[tileId], x, y)
end
end
end
endLoading Maps from Files
Simple Format
-- level1.txt
1 1 1 1 1
1 0 0 0 1
1 0 2 0 1
1 0 0 0 1
1 1 1 1 1function loadMap(filename)
local map = {}
local content = love.filesystem.read(filename)
for line in content:gmatch("[^\n]+") do
local row = {}
for tile in line:gmatch("%d+") do
table.insert(row, tonumber(tile))
end
table.insert(map, row)
end
return map
endString-Based Maps
More readable for simple games:
local mapString = [[
########
#......#
#..##..#
#......#
########
]]
function parseMap(str)
local map = {}
local charToTile = {
["#"] = 1, -- Wall
["."] = 0, -- Empty
["@"] = 2, -- Player spawn
["$"] = 3, -- Collectible
}
for line in str:gmatch("[^\n]+") do
local row = {}
for char in line:gmatch(".") do
table.insert(row, charToTile[char] or 0)
end
if #row > 0 then
table.insert(map, row)
end
end
return map
endTile Collision
Grid-Based Collision
function isSolid(tileId)
return tileId == 1 -- Wall tiles are solid
end
function getTileAt(x, y)
local col = math.floor(x / tileW) + 1
local row = math.floor(y / tileH) + 1
if row >= 1 and row <= #map and col >= 1 and col <= #map[row] then
return map[row][col]
end
return 1 -- Out of bounds = solid
end
function canMoveTo(x, y, width, height)
-- Check all four corners
local corners = {
{x, y}, -- Top-left
{x + width - 1, y}, -- Top-right
{x, y + height - 1}, -- Bottom-left
{x + width - 1, y + height - 1} -- Bottom-right
}
for _, corner in ipairs(corners) do
if isSolid(getTileAt(corner[1], corner[2])) then
return false
end
end
return true
endPlayer Movement with Collision
function Player:update(dt)
local newX = self.x
local newY = self.y
if love.keyboard.isDown("left") then
newX = newX - self.speed * dt
end
if love.keyboard.isDown("right") then
newX = newX + self.speed * dt
end
if love.keyboard.isDown("up") then
newY = newY - self.speed * dt
end
if love.keyboard.isDown("down") then
newY = newY + self.speed * dt
end
-- Check X movement
if canMoveTo(newX, self.y, self.w, self.h) then
self.x = newX
end
-- Check Y movement
if canMoveTo(self.x, newY, self.w, self.h) then
self.y = newY
end
endCamera Scrolling
For maps larger than the screen:
local camera = {x = 0, y = 0}
local mapW = #map[1] * tileW
local mapH = #map * tileH
function updateCamera()
-- Center camera on player
camera.x = player.x - screenW / 2
camera.y = player.y - screenH / 2
-- Clamp to map bounds
camera.x = math.max(0, math.min(camera.x, mapW - screenW))
camera.y = math.max(0, math.min(camera.y, mapH - screenH))
end
function love.draw()
love.graphics.push()
love.graphics.translate(-camera.x, -camera.y)
-- Only draw visible tiles
local startCol = math.floor(camera.x / tileW) + 1
local endCol = math.ceil((camera.x + screenW) / tileW) + 1
local startRow = math.floor(camera.y / tileH) + 1
local endRow = math.ceil((camera.y + screenH) / tileH) + 1
for row = startRow, math.min(endRow, #map) do
for col = startCol, math.min(endCol, #map[row]) do
local tileId = map[row][col]
if tileId > 0 then
love.graphics.draw(tileset, tiles[tileId],
(col - 1) * tileW, (row - 1) * tileH)
end
end
end
player:draw()
love.graphics.pop()
endMultiple Layers
Separate visual layers from collision:
local layers = {
background = { ... }, -- Decorative, no collision
collision = { ... }, -- Solid tiles
foreground = { ... }, -- Drawn on top of player
}
function love.draw()
drawLayer(layers.background)
drawPlayer()
drawLayer(layers.foreground)
end
function checkCollision(x, y)
return getTileAt(layers.collision, x, y) > 0
endTile Properties
For complex games, store tile metadata:
local tileProperties = {
[1] = { solid = true, name = "wall" },
[2] = { solid = false, name = "floor" },
[3] = { solid = false, name = "water", slows = true },
[4] = { solid = true, name = "door", openable = true },
}
function isSolid(tileId)
local props = tileProperties[tileId]
return props and props.solid
endTiled Map Editor Integration
Tiled is a popular free map editor. Export as JSON or Lua:
-- Load Tiled Lua export
local mapData = require("level1")
function love.load()
-- Parse Tiled format
tileW = mapData.tilewidth
tileH = mapData.tileheight
for _, layer in ipairs(mapData.layers) do
if layer.type == "tilelayer" then
-- Convert 1D array to 2D
local map = {}
local i = 1
for row = 1, layer.height do
map[row] = {}
for col = 1, layer.width do
map[row][col] = layer.data[i]
i = i + 1
end
end
end
end
endOr use a library like STI (Simple Tiled Implementation).
Best Practices
1. Power-of-two tilesets: 32x32, 64x64, 128x128 tiles work best 2. Separate collision from visuals: Not all visible tiles block movement 3. Cull off-screen tiles: Only draw what's visible 4. Use sprite batches: For many tiles, batch drawing is faster 5. Design for the grid: Align objects to tile boundaries when possible
Sprite Batches
For better performance with many tiles:
function love.load()
spriteBatch = love.graphics.newSpriteBatch(tileset, 1000)
rebuildBatch()
end
function rebuildBatch()
spriteBatch:clear()
for row = 1, #map do
for col = 1, #map[row] do
local tileId = map[row][col]
if tileId > 0 then
spriteBatch:add(tiles[tileId],
(col - 1) * tileW,
(row - 1) * tileH)
end
end
end
end
function love.draw()
love.graphics.draw(spriteBatch)
end