Tutorial25 min readJuly 27, 2025

Building Your First Game with Lua: Step-by-Step Tutorial

By Michael Chen

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:

  1. Zip all your game files (main.lua, conf.lua, and all directories)
  2. Rename the .zip extension to .love
  3. 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

MC

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.