There's no better way to learn game development than by building an actual game. In this comprehensive tutorial, we'll create a complete 2D platformer game from scratch using Lua. You'll learn about game architecture, physics, collision detection, and everything else needed to create a polished, playable game.
What We're Building
We'll create "Crystal Collector," a classic 2D platformer where players navigate through levels, avoiding enemies and collecting crystals. The game will feature:
- Smooth player movement with gravity and jumping
- Collectible items with score tracking
- Enemy AI with patrol patterns
- Multiple levels with increasing difficulty
- Sound effects and background music
- A polished menu system
Prerequisites
This tutorial assumes you have basic knowledge of Lua syntax and have a development environment set up. We'll be using the LÖVE2D framework, which you can download from love2d.org.
Setting Up the Project Structure
Good organization is crucial for game development. Let's start by creating a clean project structure that will scale as our game grows. Create a new directory for your game and set up the following structure:
crystal-collector/
├── main.lua -- Entry point
├── conf.lua -- LÖVE2D configuration
├── game/
│ ├── states/ -- Game states (menu, gameplay, etc.)
│ ├── entities/ -- Player, enemies, items
│ ├── levels/ -- Level data and loading
│ └── utils/ -- Helper functions
├── assets/
│ ├── images/ -- Sprites and backgrounds
│ ├── sounds/ -- Sound effects
│ └── music/ -- Background music
└── lib/ -- Third-party libraries
Initial Configuration
Let's start with the LÖVE2D configuration file. This sets up our game window and basic settings:
-- conf.lua
function love.conf(t)
t.title = "Crystal Collector"
t.version = "11.4"
t.window.width = 1024
t.window.height = 768
t.window.resizable = false
t.window.vsync = true
-- Disable unused modules for better performance
t.modules.joystick = false
t.modules.physics = false
t.modules.touch = false
end
Building the Game Architecture
Before diving into gameplay, we need a solid architecture. We'll implement a state management system that allows us to switch between different game screens (menu, gameplay, game over, etc.) cleanly.
The State Manager
-- game/states/StateManager.lua
local StateManager = {}
StateManager.__index = StateManager
function StateManager:new()
local self = setmetatable({}, StateManager)
self.states = {}
self.current = nil
self.currentName = nil
return self
end
function StateManager:register(name, state)
self.states[name] = state
end
function StateManager:switch(name, ...)
assert(self.states[name], "State '" .. name .. "' does not exist!")
-- Exit current state
if self.current and self.current.exit then
self.current:exit()
end
-- Enter new state
self.currentName = name
self.current = self.states[name]
if self.current.enter then
self.current:enter(...)
end
end
function StateManager:update(dt)
if self.current and self.current.update then
self.current:update(dt)
end
end
function StateManager:draw()
if self.current and self.current.draw then
self.current:draw()
end
end
function StateManager:keypressed(key, scancode, isrepeat)
if self.current and self.current.keypressed then
self.current:keypressed(key, scancode, isrepeat)
end
end
function StateManager:keyreleased(key, scancode)
if self.current and self.current.keyreleased then
self.current:keyreleased(key, scancode)
end
end
return StateManager
Main Entry Point
Now let's set up our main.lua file to use the state manager:
-- main.lua
local StateManager = require("game.states.StateManager")
-- Global game object
Game = {
stateManager = StateManager:new(),
screenWidth = love.graphics.getWidth(),
screenHeight = love.graphics.getHeight(),
}
function love.load()
-- Set default filter for pixel art
love.graphics.setDefaultFilter("nearest", "nearest")
-- Load game states
local menuState = require("game.states.MenuState")
local gameplayState = require("game.states.GameplayState")
Game.stateManager:register("menu", menuState)
Game.stateManager:register("gameplay", gameplayState)
-- Start with menu
Game.stateManager:switch("menu")
end
function love.update(dt)
Game.stateManager:update(dt)
end
function love.draw()
Game.stateManager:draw()
end
function love.keypressed(key, scancode, isrepeat)
Game.stateManager:keypressed(key, scancode, isrepeat)
end
function love.keyreleased(key, scancode)
Game.stateManager:keyreleased(key, scancode)
end
Creating the Player Character
The heart of any platformer is the player character. We'll create a player with smooth movement, gravity, and jumping mechanics. This is where game development gets really fun!
Player Entity
-- game/entities/Player.lua
local Player = {}
Player.__index = Player
function Player:new(x, y)
local self = setmetatable({}, Player)
-- Position and size
self.x = x or 100
self.y = y or 100
self.width = 32
self.height = 48
-- Physics
self.velocityX = 0
self.velocityY = 0
self.speed = 300
self.jumpPower = -600
self.gravity = 1800
self.maxFallSpeed = 800
-- State
self.grounded = false
self.facing = 1 -- 1 for right, -1 for left
self.state = "idle" -- idle, running, jumping, falling
-- Stats
self.health = 3
self.score = 0
self.crystals = 0
-- Animation (simplified for tutorial)
self.animationTimer = 0
self.currentFrame = 1
return self
end
function Player:update(dt, level)
-- Store previous position for collision resolution
local prevX = self.x
local prevY = self.y
-- Handle input
self:handleInput(dt)
-- Apply gravity
if not self.grounded then
self.velocityY = math.min(self.velocityY + self.gravity * dt, self.maxFallSpeed)
end
-- Update position
self.x = self.x + self.velocityX * dt
self.y = self.y + self.velocityY * dt
-- Check collisions with level
self:checkCollisions(level, prevX, prevY)
-- Update animation
self:updateAnimation(dt)
-- Update state
self:updateState()
end
function Player:handleInput(dt)
local moveX = 0
-- Horizontal movement
if love.keyboard.isDown("left", "a") then
moveX = -1
self.facing = -1
elseif love.keyboard.isDown("right", "d") then
moveX = 1
self.facing = 1
end
self.velocityX = moveX * self.speed
-- Jumping
if love.keyboard.isDown("space", "up", "w") and self.grounded then
self.velocityY = self.jumpPower
self.grounded = false
-- Play jump sound here
end
end
function Player:checkCollisions(level, prevX, prevY)
-- Simple AABB collision with level tiles
local tileSize = level.tileSize
-- Check horizontal collisions
local leftTile = math.floor(self.x / tileSize)
local rightTile = math.floor((self.x + self.width) / tileSize)
local topTile = math.floor(self.y / tileSize)
local bottomTile = math.floor((self.y + self.height) / tileSize)
-- Reset grounded state
self.grounded = false
-- Check each potentially colliding tile
for y = topTile, bottomTile do
for x = leftTile, rightTile do
if level:isSolid(x, y) then
-- Calculate tile bounds
local tileX = x * tileSize
local tileY = y * tileSize
-- Resolve collision
if self:overlapsRect(tileX, tileY, tileSize, tileSize) then
-- Determine collision direction based on previous position
local overlapLeft = (self.x + self.width) - tileX
local overlapRight = (tileX + tileSize) - self.x
local overlapTop = (self.y + self.height) - tileY
local overlapBottom = (tileY + tileSize) - self.y
-- Find smallest overlap
local minOverlapX = math.min(overlapLeft, overlapRight)
local minOverlapY = math.min(overlapTop, overlapBottom)
if minOverlapX < minOverlapY then
-- Horizontal collision
if overlapLeft < overlapRight then
self.x = tileX - self.width
else
self.x = tileX + tileSize
end
self.velocityX = 0
else
-- Vertical collision
if overlapTop < overlapBottom then
-- Landing on ground
self.y = tileY - self.height
self.grounded = true
self.velocityY = 0
else
-- Hitting ceiling
self.y = tileY + tileSize
self.velocityY = 0
end
end
end
end
end
end
-- Keep player in bounds
self.x = math.max(0, math.min(self.x, level.width * tileSize - self.width))
-- Check if player fell off the map
if self.y > level.height * tileSize then
self:die()
end
end
function Player:overlapsRect(x, y, width, height)
return self.x < x + width and
self.x + self.width > x and
self.y < y + height and
self.y + self.height > y
end
function Player:updateState()
if not self.grounded then
if self.velocityY < 0 then
self.state = "jumping"
else
self.state = "falling"
end
elseif math.abs(self.velocityX) > 10 then
self.state = "running"
else
self.state = "idle"
end
end
function Player:updateAnimation(dt)
self.animationTimer = self.animationTimer + dt
-- Simple frame cycling based on state
if self.state == "running" then
if self.animationTimer > 0.1 then
self.currentFrame = self.currentFrame % 4 + 1
self.animationTimer = 0
end
elseif self.state == "idle" then
if self.animationTimer > 0.5 then
self.currentFrame = self.currentFrame % 2 + 1
self.animationTimer = 0
end
else
self.currentFrame = 1
end
end
function Player:draw()
-- For now, just draw a colored rectangle
-- In a real game, you'd draw the appropriate sprite frame
love.graphics.push()
love.graphics.translate(self.x + self.width/2, self.y + self.height/2)
love.graphics.scale(self.facing, 1)
-- Draw player
love.graphics.setColor(0.2, 0.6, 1) -- Blue
love.graphics.rectangle("fill", -self.width/2, -self.height/2, self.width, self.height)
-- Draw a simple face
love.graphics.setColor(1, 1, 1)
love.graphics.circle("fill", -8, -8, 3)
love.graphics.circle("fill", 8, -8, 3)
love.graphics.pop()
end
function Player:collectCrystal(value)
self.crystals = self.crystals + 1
self.score = self.score + (value or 10)
-- Play collect sound here
end
function Player:takeDamage(amount)
self.health = self.health - (amount or 1)
if self.health <= 0 then
self:die()
end
-- Play hurt sound here
end
function Player:die()
-- Handle player death
print("Player died!")
-- In a real game, you'd trigger game over or respawn
end
return Player
Creating the Game World
Now that we have a player, we need a world for them to explore. We'll create a simple tile-based level system that's easy to design and modify.
Level System
-- game/levels/Level.lua
local Level = {}
Level.__index = Level
function Level:new()
local self = setmetatable({}, Level)
self.tileSize = 32
self.width = 32
self.height = 24
-- Initialize empty map
self.tiles = {}
for y = 1, self.height do
self.tiles[y] = {}
for x = 1, self.width do
self.tiles[y][x] = 0
end
end
-- Lists for game objects
self.crystals = {}
self.enemies = {}
self.platforms = {}
return self
end
function Level:loadFromString(levelData)
-- Parse level from string representation
local lines = {}
for line in levelData:gmatch("[^
]+") do
table.insert(lines, line)
end
for y, line in ipairs(lines) do
for x = 1, #line do
local char = line:sub(x, x)
self:setTileFromChar(x, y, char)
end
end
end
function Level:setTileFromChar(x, y, char)
-- Map characters to tile types and entities
if char == "#" then
-- Solid wall
self.tiles[y][x] = 1
elseif char == "." then
-- Empty space
self.tiles[y][x] = 0
elseif char == "C" then
-- Crystal
self.tiles[y][x] = 0
table.insert(self.crystals, {
x = (x - 1) * self.tileSize + self.tileSize/2,
y = (y - 1) * self.tileSize + self.tileSize/2,
collected = false
})
elseif char == "E" then
-- Enemy spawn point
self.tiles[y][x] = 0
local Enemy = require("game.entities.Enemy")
table.insert(self.enemies, Enemy:new(
(x - 1) * self.tileSize,
(y - 1) * self.tileSize
))
elseif char == "P" then
-- Player spawn point
self.tiles[y][x] = 0
self.playerSpawn = {
x = (x - 1) * self.tileSize,
y = (y - 1) * self.tileSize
}
end
end
function Level:isSolid(x, y)
if x < 1 or x > self.width or y < 1 or y > self.height then
return true -- Treat out of bounds as solid
end
return self.tiles[y][x] == 1
end
function Level:update(dt, player)
-- Update enemies
for _, enemy in ipairs(self.enemies) do
if enemy.active then
enemy:update(dt, self)
-- Check collision with player
if player:overlapsRect(enemy.x, enemy.y, enemy.width, enemy.height) then
player:takeDamage(1)
-- Knock back player
player.velocityX = (player.x - enemy.x) * 10
player.velocityY = -300
end
end
end
-- Check crystal collection
for _, crystal in ipairs(self.crystals) do
if not crystal.collected then
local dx = player.x + player.width/2 - crystal.x
local dy = player.y + player.height/2 - crystal.y
local distance = math.sqrt(dx*dx + dy*dy)
if distance < 20 then
crystal.collected = true
player:collectCrystal(10)
end
end
end
end
function Level:draw()
-- Draw tiles
love.graphics.setColor(0.5, 0.5, 0.5) -- Gray for walls
for y = 1, self.height do
for x = 1, self.width do
if self.tiles[y][x] == 1 then
love.graphics.rectangle("fill",
(x - 1) * self.tileSize,
(y - 1) * self.tileSize,
self.tileSize,
self.tileSize
)
-- Draw simple borders
love.graphics.setColor(0.3, 0.3, 0.3)
love.graphics.rectangle("line",
(x - 1) * self.tileSize,
(y - 1) * self.tileSize,
self.tileSize,
self.tileSize
)
love.graphics.setColor(0.5, 0.5, 0.5)
end
end
end
-- Draw crystals
love.graphics.setColor(1, 1, 0) -- Yellow
for _, crystal in ipairs(self.crystals) do
if not crystal.collected then
love.graphics.push()
love.graphics.translate(crystal.x, crystal.y)
-- Animate rotation
love.graphics.rotate(love.timer.getTime() * 2)
-- Draw diamond shape
love.graphics.polygon("fill",
0, -10,
10, 0,
0, 10,
-10, 0
)
love.graphics.pop()
end
end
-- Draw enemies
for _, enemy in ipairs(self.enemies) do
if enemy.active then
enemy:draw()
end
end
love.graphics.setColor(1, 1, 1) -- Reset color
end
-- Example level 1
Level.level1Data = [[
################################
#..............................#
#..............................#
#..P...........................#
#..............................#
#.....C......C......C..........#
#...####..####..####...........#
#..............................#
#..............................#
#..........E...................#
#########################......#
#..............................#
#..............................#
#...C..................C.......#
#...##########.....#####.......#
#..............................#
#..............................#
#.E....................E.......#
####........................####
#..............................#
#...C...C...C...C...C...C......#
#..............................#
#..............................#
################################
]]
return Level
Adding Enemies
A platformer isn't complete without enemies to avoid. Let's create a simple enemy that patrols back and forth:
-- game/entities/Enemy.lua
local Enemy = {}
Enemy.__index = Enemy
function Enemy:new(x, y)
local self = setmetatable({}, Enemy)
-- Position and size
self.x = x or 0
self.y = y or 0
self.width = 28
self.height = 28
-- Movement
self.velocityX = 50 -- Patrol speed
self.velocityY = 0
self.direction = 1
self.gravity = 1800
-- State
self.active = true
self.grounded = false
self.patrolDistance = 100
self.startX = x
-- Animation
self.animationTimer = 0
self.currentFrame = 1
return self
end
function Enemy:update(dt, level)
if not self.active then return end
-- Apply gravity
if not self.grounded then
self.velocityY = math.min(self.velocityY + self.gravity * dt, 800)
end
-- Update position
self.x = self.x + self.velocityX * self.direction * dt
self.y = self.y + self.velocityY * dt
-- Simple patrol behavior
if math.abs(self.x - self.startX) > self.patrolDistance then
self.direction = -self.direction
end
-- Check for ledges (turn around if about to fall)
local checkX = self.x + (self.direction * self.width)
local checkY = self.y + self.height + 1
local tileX = math.floor(checkX / level.tileSize) + 1
local tileY = math.floor(checkY / level.tileSize) + 1
if not level:isSolid(tileX, tileY) then
self.direction = -self.direction
end
-- Simple ground collision
local bottomY = self.y + self.height
local tileY = math.floor(bottomY / level.tileSize) + 1
local tileX1 = math.floor(self.x / level.tileSize) + 1
local tileX2 = math.floor((self.x + self.width) / level.tileSize) + 1
self.grounded = false
if level:isSolid(tileX1, tileY) or level:isSolid(tileX2, tileY) then
self.y = (tileY - 1) * level.tileSize - self.height
self.velocityY = 0
self.grounded = true
end
-- Update animation
self.animationTimer = self.animationTimer + dt
if self.animationTimer > 0.2 then
self.currentFrame = self.currentFrame % 2 + 1
self.animationTimer = 0
end
end
function Enemy:draw()
if not self.active then return end
-- Draw enemy as a red square with eyes
love.graphics.setColor(1, 0.2, 0.2) -- Red
love.graphics.rectangle("fill", self.x, self.y, self.width, self.height)
-- Draw eyes
love.graphics.setColor(1, 1, 1)
local eyeY = self.y + 8
if self.direction > 0 then
love.graphics.circle("fill", self.x + 8, eyeY, 3)
love.graphics.circle("fill", self.x + 20, eyeY, 3)
else
love.graphics.circle("fill", self.x + 8, eyeY, 3)
love.graphics.circle("fill", self.x + 20, eyeY, 3)
end
-- Draw pupils
love.graphics.setColor(0, 0, 0)
local pupilOffset = self.direction * 1.5
love.graphics.circle("fill", self.x + 8 + pupilOffset, eyeY, 1.5)
love.graphics.circle("fill", self.x + 20 + pupilOffset, eyeY, 1.5)
love.graphics.setColor(1, 1, 1)
end
function Enemy:takeDamage()
self.active = false
-- In a full game, you might play a death animation
end
return Enemy
Creating Game States
Now let's create the actual game states that tie everything together:
Menu State
-- game/states/MenuState.lua
local MenuState = {}
function MenuState:enter()
self.options = {
"Start Game",
"How to Play",
"Quit"
}
self.selected = 1
end
function MenuState:update(dt)
-- Menu logic is handled in keypressed
end
function MenuState:draw()
-- Draw title
love.graphics.setFont(love.graphics.newFont(48))
love.graphics.setColor(1, 1, 0) -- Yellow
love.graphics.printf("Crystal Collector",
0, 100, Game.screenWidth, "center")
-- Draw menu options
love.graphics.setFont(love.graphics.newFont(24))
for i, option in ipairs(self.options) do
if i == self.selected then
love.graphics.setColor(1, 1, 1) -- White for selected
love.graphics.print("> " .. option,
Game.screenWidth/2 - 100, 250 + i * 50)
else
love.graphics.setColor(0.7, 0.7, 0.7) -- Gray for unselected
love.graphics.print(" " .. option,
Game.screenWidth/2 - 100, 250 + i * 50)
end
end
-- Instructions
love.graphics.setColor(0.5, 0.5, 0.5)
love.graphics.setFont(love.graphics.newFont(16))
love.graphics.printf("Use arrow keys to select, Enter to confirm",
0, Game.screenHeight - 50, Game.screenWidth, "center")
end
function MenuState:keypressed(key)
if key == "up" then
self.selected = math.max(1, self.selected - 1)
elseif key == "down" then
self.selected = math.min(#self.options, self.selected + 1)
elseif key == "return" then
if self.selected == 1 then
Game.stateManager:switch("gameplay")
elseif self.selected == 2 then
-- Show instructions
print("Arrow keys/WASD to move, Space to jump")
elseif self.selected == 3 then
love.event.quit()
end
end
end
return MenuState
Gameplay State
-- game/states/GameplayState.lua
local GameplayState = {}
local Player = require("game.entities.Player")
local Level = require("game.levels.Level")
function GameplayState:enter()
-- Create level
self.level = Level:new()
self.level:loadFromString(Level.level1Data)
-- Create player at spawn point
local spawn = self.level.playerSpawn or {x = 100, y = 100}
self.player = Player:new(spawn.x, spawn.y)
-- Camera
self.camera = {
x = 0,
y = 0,
scale = 1
}
-- UI
self.showDebug = false
end
function GameplayState:update(dt)
-- Update player
self.player:update(dt, self.level)
-- Update level (enemies, etc.)
self.level:update(dt, self.player)
-- Update camera to follow player
self:updateCamera()
-- Check win condition
if self:checkWinCondition() then
print("Level Complete!")
-- In a full game, load next level or show victory screen
end
end
function GameplayState:updateCamera()
-- Simple camera that follows player with some smoothing
local targetX = self.player.x + self.player.width/2 - Game.screenWidth/2
local targetY = self.player.y + self.player.height/2 - Game.screenHeight/2
-- Smooth camera movement
self.camera.x = self.camera.x + (targetX - self.camera.x) * 0.1
self.camera.y = self.camera.y + (targetY - self.camera.y) * 0.1
-- Clamp camera to level bounds
local maxX = self.level.width * self.level.tileSize - Game.screenWidth
local maxY = self.level.height * self.level.tileSize - Game.screenHeight
self.camera.x = math.max(0, math.min(self.camera.x, maxX))
self.camera.y = math.max(0, math.min(self.camera.y, maxY))
end
function GameplayState:checkWinCondition()
-- Check if all crystals are collected
for _, crystal in ipairs(self.level.crystals) do
if not crystal.collected then
return false
end
end
return true
end
function GameplayState:draw()
-- Apply camera transform
love.graphics.push()
love.graphics.translate(-self.camera.x, -self.camera.y)
-- Draw level
self.level:draw()
-- Draw player
self.player:draw()
love.graphics.pop()
-- Draw UI (not affected by camera)
self:drawUI()
end
function GameplayState:drawUI()
-- Draw health
love.graphics.setColor(1, 0.2, 0.2)
for i = 1, self.player.health do
love.graphics.rectangle("fill", 10 + (i-1) * 35, 10, 30, 30)
love.graphics.setColor(1, 0.5, 0.5)
love.graphics.rectangle("line", 10 + (i-1) * 35, 10, 30, 30)
love.graphics.setColor(1, 0.2, 0.2)
end
-- Draw score
love.graphics.setColor(1, 1, 1)
love.graphics.setFont(love.graphics.newFont(20))
love.graphics.print("Score: " .. self.player.score, 10, 50)
love.graphics.print("Crystals: " .. self.player.crystals, 10, 80)
-- Debug info
if self.showDebug then
love.graphics.setFont(love.graphics.newFont(12))
love.graphics.print("FPS: " .. love.timer.getFPS(), 10, 120)
love.graphics.print("Player State: " .. self.player.state, 10, 140)
love.graphics.print("Velocity: " ..
string.format("%.1f, %.1f", self.player.velocityX, self.player.velocityY),
10, 160)
end
end
function GameplayState:keypressed(key)
if key == "escape" then
Game.stateManager:switch("menu")
elseif key == "f3" then
self.showDebug = not self.showDebug
end
end
return GameplayState
Adding Polish and Game Feel
A game isn't just about mechanics—it's about how it feels to play. Let's add some polish to make our game more enjoyable.
Particle Effects
Simple particle effects can add a lot of visual interest. Here's a basic particle system for collection effects:
-- game/utils/ParticleSystem.lua
local ParticleSystem = {}
ParticleSystem.__index = ParticleSystem
function ParticleSystem:new()
local self = setmetatable({}, ParticleSystem)
self.particles = {}
return self
end
function ParticleSystem:emit(x, y, count, options)
options = options or {}
for i = 1, count do
local angle = math.random() * math.pi * 2
local speed = options.speed or math.random(50, 150)
table.insert(self.particles, {
x = x,
y = y,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed - 50,
life = options.lifetime or 1,
maxLife = options.lifetime or 1,
color = options.color or {1, 1, 0},
size = options.size or 4
})
end
end
function ParticleSystem:update(dt)
for i = #self.particles, 1, -1 do
local p = self.particles[i]
-- Update position
p.x = p.x + p.vx * dt
p.y = p.y + p.vy * dt
-- Apply gravity
p.vy = p.vy + 300 * dt
-- Update lifetime
p.life = p.life - dt
-- Remove dead particles
if p.life <= 0 then
table.remove(self.particles, i)
end
end
end
function ParticleSystem:draw()
for _, p in ipairs(self.particles) do
local alpha = p.life / p.maxLife
love.graphics.setColor(p.color[1], p.color[2], p.color[3], alpha)
love.graphics.circle("fill", p.x, p.y, p.size * alpha)
end
love.graphics.setColor(1, 1, 1)
end
return ParticleSystem
Sound Effects
Sound is crucial for game feel. Here's a simple sound manager:
-- game/utils/SoundManager.lua
local SoundManager = {}
SoundManager.__index = SoundManager
function SoundManager:new()
local self = setmetatable({}, SoundManager)
self.sounds = {}
self.music = nil
self.musicVolume = 0.5
self.sfxVolume = 0.7
return self
end
function SoundManager:loadSound(name, path)
self.sounds[name] = love.audio.newSource(path, "static")
end
function SoundManager:playSound(name, volume)
if self.sounds[name] then
self.sounds[name]:setVolume((volume or 1) * self.sfxVolume)
self.sounds[name]:play()
end
end
function SoundManager:loadMusic(path)
self.music = love.audio.newSource(path, "stream")
self.music:setLooping(true)
self.music:setVolume(self.musicVolume)
end
function SoundManager:playMusic()
if self.music then
self.music:play()
end
end
function SoundManager:stopMusic()
if self.music then
self.music:stop()
end
end
return SoundManager
Performance Optimization
As your game grows, performance becomes crucial. Here are some optimization techniques specific to Lua game development:
Object Pooling
Instead of creating and destroying objects frequently (which triggers garbage collection), reuse objects from a pool:
-- game/utils/ObjectPool.lua
local ObjectPool = {}
ObjectPool.__index = ObjectPool
function ObjectPool:new(createFunc, resetFunc, initialSize)
local self = setmetatable({}, ObjectPool)
self.createFunc = createFunc
self.resetFunc = resetFunc
self.available = {}
self.active = {}
-- Pre-populate pool
for i = 1, (initialSize or 10) do
table.insert(self.available, createFunc())
end
return self
end
function ObjectPool:get()
local obj
if #self.available > 0 then
obj = table.remove(self.available)
else
obj = self.createFunc()
end
table.insert(self.active, obj)
obj.poolIndex = #self.active
return obj
end
function ObjectPool:release(obj)
if obj.poolIndex then
table.remove(self.active, obj.poolIndex)
self.resetFunc(obj)
table.insert(self.available, obj)
-- Update indices
for i = obj.poolIndex, #self.active do
self.active[i].poolIndex = i
end
end
end
function ObjectPool:releaseAll()
for i = #self.active, 1, -1 do
self:release(self.active[i])
end
end
-- Example usage for bullets
local bulletPool = ObjectPool:new(
function() -- Create function
return {x = 0, y = 0, vx = 0, vy = 0, active = false}
end,
function(bullet) -- Reset function
bullet.active = false
bullet.x = 0
bullet.y = 0
end,
50 -- Initial size
)
return ObjectPool
Testing and Debugging
Debugging is an essential part of game development. Here are some techniques specific to Lua game development:
Debug Console
-- game/utils/DebugConsole.lua
local DebugConsole = {}
DebugConsole.__index = DebugConsole
function DebugConsole:new()
local self = setmetatable({}, DebugConsole)
self.visible = false
self.logs = {}
self.maxLogs = 20
self.commands = {}
-- Register default commands
self:registerCommand("help", function()
self:log("Available commands:")
for cmd, _ in pairs(self.commands) do
self:log(" " .. cmd)
end
end)
self:registerCommand("clear", function()
self.logs = {}
end)
return self
end
function DebugConsole:log(message)
table.insert(self.logs, {
text = tostring(message),
time = love.timer.getTime()
})
-- Remove old logs
while #self.logs > self.maxLogs do
table.remove(self.logs, 1)
end
end
function DebugConsole:registerCommand(name, func)
self.commands[name] = func
end
function DebugConsole:executeCommand(input)
local parts = {}
for part in input:gmatch("%S+") do
table.insert(parts, part)
end
local command = parts[1]
if command and self.commands[command] then
table.remove(parts, 1)
self.commands[command](unpack(parts))
else
self:log("Unknown command: " .. (command or ""))
end
end
function DebugConsole:draw()
if not self.visible then return end
-- Draw background
love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", 0, 0, Game.screenWidth, 300)
-- Draw logs
love.graphics.setColor(1, 1, 1)
love.graphics.setFont(love.graphics.newFont(12))
for i, log in ipairs(self.logs) do
love.graphics.print(log.text, 10, 10 + (i-1) * 15)
end
love.graphics.setColor(1, 1, 1)
end
return DebugConsole
Packaging and Distribution
Once your game is complete, you'll want to share it with the world. LÖVE2D makes distribution relatively straightforward.
Creating a .love File
The simplest way to distribute your game is as a .love file:
- Zip all your game files (main.lua, conf.lua, and all directories)
- Rename the .zip extension to .love
- Anyone with LÖVE2D installed can run your game by double-clicking the file
Creating Standalone Executables
For wider distribution, you can create standalone executables:
Platform-Specific Distribution
- Windows: Concatenate love.exe with your .love file
- macOS: Create an .app bundle with your game inside
- Linux: Use AppImage or provide the .love file with instructions
Conclusion and Next Steps
Congratulations! You've built a complete 2D platformer game from scratch using Lua. You've learned about game architecture, player physics, collision detection, enemy AI, and much more. But this is just the beginning of what's possible.
Ways to Extend Your Game
- Add more enemy types with different behaviors
- Create power-ups that give the player new abilities
- Implement a level editor for easy content creation
- Add boss battles with complex patterns
- Create cutscenes and story elements
- Implement online leaderboards
- Add multiplayer support
Remember, game development is an iterative process. Start small, test often, and gradually add features. Most importantly, have fun creating and don't be afraid to experiment!
Keep creating, keep learning, and share your games with the world. The game development community is always excited to see new creations!
About the Author
Michael Chen
Game Developer & Educator
Michael has been creating games for over 15 years and teaching game development for the last 8. He specializes in 2D game development and has published several successful indie titles using Lua and various game engines.