Home - JakeTurner616/pygame-lua-bindings GitHub Wiki

Pygame in Lua

Philosophy

The idea is to simplify development of simple 2d games by using a custom Lua framework with an event registration system. This reduces boilerplate code, making event handling more intuitive and allows simple games to be written somewhat easier.

Introduction

Welcome to the Pygame Lua Game Development Tutorial! This guide will walk you through the process of creating games using Lua and Pygame. We'll start with a simple "Hello World" program and gradually build up to more complex games. By the end of this tutorial, you'll have a solid understanding of what this framework is all about.

Setting Up

To get started, you'll need to set up your environment to run Lua scripts with Pygame, Luckily this part is really easy:

  1. Install the framework.
  2. Edit the test script in the test_script directory.
  3. Run main.py to execute the Lua script.

Understanding the mainloop and event system

The main loop and event handling are fundamental to game development, and pygame is no different. Ensuring the game remains interactive and responsive is a crucial job of the mainloop and event handlers and are difficult to maintain for quick prototyping pygame code as this task is left to the programmer. Using Pygame with Lua can potentially offer a simplified and streamlined way to handle these processes compared to traditional Python implementations. This wiki highlights these differences and demonstrates the ease of use with Lua.

In a standard Pygame application written in Python, the main loop handles event processing, updating the game state, and rendering:


import pygame
pygame.init()

# Setup
screen = pygame.display.set_mode((800, 600))
running = True

# Main loop
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False

    # Update game state
    # Draw to the screen
    screen.fill((0, 0, 0))
    pygame.display.flip()

pygame.quit()

Using Pygame with Lua, the main loop and event handling are simplified and abstracted even more through an event registration system. This system could make it quite easy to manage events and game state updates:


-- Initialize the game state and variables
local running = true

-- Define event handler for keydown
local function on_keydown(event)
    if event.key == K_ESCAPE then
        running = false
    elseif event.key == K_RIGHT then
        -- Move right
    elseif event.key == K_LEFT then
        -- Move left
    end
end

-- Register event handlers
register_event_handler('on_keydown', on_keydown)

-- Function to handle events
local function handle_events()
    for _, event in ipairs(get_events()) do
        if event.type == "QUIT" then
            running = false
        elseif event.type == "KEYDOWN" then
            on_keydown(event)
        end
    end
end

-- Function to update the game state
local function update_game()
    -- Game logic goes here
end

-- Function to render the game
local function render_game()
    clear_canvas()
    -- Drawing code goes here
    flip_display()
end

-- Main loop
while running do
    handle_events()
    update_game()
    render_game()
end

Explanation:

The register_event_handler function is used to bind specific functions to handle events like on_keydown. This makes it easy to manage and extend event handling. The handle_events function processes events using the registered handlers, simplifying the event loop. The main loop continuously calls handle_events, update_game, and render_game to keep the game responsive.

Examples (easy to intermediate)

Example 1: Hello World

Let's begin with the classic "Hello World" program. This will introduce you to the basic structure of a Lua script using Pygame.

-- Clear the screen and draw some shapes
clear_canvas()

-- Draw Blue text
draw_text(50, 50, "Hello, Pygame!", "Arial", 30, 0x0000FF)

-- Update display
flip_display()

In this script, we:

Clear the canvas to ensure there is nothing left over from previous drawings. Draw the text "Hello, Pygame!" in blue at the specified coordinates (50, 50). Update the display to show the drawn text.

Example 2: Moving a Shape

Next, we'll create a program that moves a shape around the screen using keyboard input.

-- Initialize the position of the shape
local shape_x, shape_y = 100, 100

-- Function to handle key events
local function handle_keys(event)
    if event.key == K_RIGHT then
        shape_x = shape_x + 10
    elseif event.key == K_LEFT then
        shape_x = shape_x - 10
    elseif event.key == K_UP then
        shape_y = shape_y - 10
    elseif event.key == K_DOWN then
        shape_y = shape_y + 10
    end
end

-- Function to draw the shape
local function draw_shape()
    clear_canvas()
    draw_rectangle(shape_x, shape_y, 50, 50, 0x00FF00) -- Draw a green rectangle
    flip_display()
end

-- Register event handlers
register_event_handler('on_keydown', handle_keys)
register_function("draw", draw_shape)

-- Start the main loop
start_main_loop()

In this script, we:

Initialize the position of the shape at (100, 100). Define a function handle_keys to handle keyboard input and update the position of the shape accordingly. Define a function draw_shape to clear the canvas and draw the shape at its current position as a green rectangle. Register the event handler for keydown events to call handle_keys. Register the draw_shape function to be called for drawing each frame. Start the main loop to continuously update and draw the shape.

Example 3: Bouncing Ball

Now, let's create an animated bouncing ball that reacts to the edges of the window.

-- Clear the screen and draw initial shapes
clear_canvas()

-- Initial positions
local circle_x, circle_y = 300, 300
local angle = math.rad(45) -- Initial angle in radians (45 degrees)
local speed = 7            -- Speed of movement

-- Function to update position (animation example)
local function update_position()
    -- Move circle
    circle_x = circle_x + math.cos(angle) * speed
    circle_y = circle_y + math.sin(angle) * speed

    -- Bounce off the edges
    local bounce_randomness = math.rad(math.random(-5, 5)) -- Smaller range for slight angle variation
    if circle_x <= 0 then
        angle = math.pi - angle                            -- Reflect horizontally on the left edge
        circle_x = 0                                       -- Reset position to within boundary
        angle = angle + bounce_randomness
    elseif circle_x >= 800 then
        angle = math.pi - angle -- Reflect horizontally on the right edge
        circle_x = 800          -- Reset position to within boundary
        angle = angle + bounce_randomness
    end

    if circle_y <= 0 then
        angle = -angle -- Reflect vertically on the top edge
        circle_y = 0   -- Reset position to within boundary
        angle = angle + bounce_randomness
    elseif circle_y >= 600 then
        angle = -angle -- Reflect vertically on the bottom edge
        circle_y = 600 -- Reset position to within boundary
        angle = angle + bounce_randomness
    end

    -- Ensure angle stays within 0 to 2*pi range
    if angle < 0 then
        angle = angle + 2 * math.pi
    elseif angle >= 2 * math.pi then
        angle = angle - 2 * math.pi
    end
end

-- Function to draw updated shapes
local function draw()
    clear_canvas()
    draw_circle(0x00FF00, { circle_x, circle_y }, 50) -- Draw a green circle
    flip_display()
end

-- Register Lua functions to be called from Python
register_function("update_position", update_position)
register_function("draw", draw)

-- Start the main loop
start_main_loop()

In this script, we:

Initialize the position, angle, and speed of the ball. Define a function update_position to update the ball's position and handle bouncing off the edges.

The ball's position is updated based on its current angle and speed. When the ball hits an edge, its angle is reflected, and a slight randomness is added to create a more natural bounce. The angle is wrapped around to stay within the range of 0 to 2π radians.

Define a function draw to clear the canvas and draw the ball at its current position as a green circle. Register the update_position and draw functions to be called from Python. Start the main loop to continuously update and draw the ball.

Example 4: Snake Game

We'll now create a classic Snake game. This example will introduce you to handling more complex game logic and using multiple objects.

-- Game constants
local SCREEN_WIDTH, SCREEN_HEIGHT, TILE_SIZE = 800, 600, 20

-- Initialize game state
local snake = {{x = 10, y = 10}}
local food = {x = 15, y = 15}
local direction = {x = 1, y = 0}
local score = 0
local input_locked = false  -- Lock for input processing

-- Draw function
local function draw_game()
    clear_canvas()

    -- Draw snake
    for _, s in ipairs(snake) do
        draw_rectangle(s.x * TILE_SIZE, s.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, "#00FF00")
    end

    -- Draw food
    draw_rectangle(food.x * TILE_SIZE, food.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, "#FF0000")

    -- Draw score
    draw_text(10, SCREEN_HEIGHT - 30, "Score: " .. score, "Arial", 20, "#FFFFFF")

    flip_display()
end

-- Event handling
register_event_handler('on_keydown', function(event)
    if input_locked then return end  -- Ignore inputs if locked
    local key_map = {
        [K_RIGHT] = {1, 0},   -- Right arrow
        [K_LEFT] = {-1, 0},  -- Left arrow
        [K_UP] = {0, -1},  -- Up arrow
        [K_DOWN] = {0, 1}    -- Down arrow
    }
    local dir = key_map[event.key]
    if dir and (dir[1] ~= -direction.x and dir[2] ~= -direction.y) then
        direction = {x = dir[1], y = dir[2]}
        input_locked = true  -- Lock input until next update
    end
end)

-- Game logic
function process_events()
    for _, e in ipairs(get_events()) do
        if e.type == "QUIT" then
            stop_main_loop()
        end
    end
end

function update_position()
    -- Move snake
    local head = {x = snake[1].x + direction.x, y = snake[1].y + direction.y}

    -- Check collision with walls or self
    if head.x < 0 or head.x >= SCREEN_WIDTH / TILE_SIZE or
       head.y < 0 or head.y >= SCREEN_HEIGHT / TILE_SIZE or
       (#snake >= 4 and (function()
            for _, s in ipairs(snake) do
                if s.x == head.x and s.y == head.y then
                    return true
                end
            end
            return false
       end)()) then
        stop_main_loop()
    end

    -- Move snake
    table.insert(snake, 1, head)

    -- Check for food collision
    if head.x == food.x and head.y == food.y then
        score = score + 1
        food = {x = math.random(0, SCREEN_WIDTH / TILE_SIZE - 1), y = math.random(0, SCREEN_HEIGHT / TILE_SIZE - 1)}
    else
        table.remove(snake)
    end

    input_locked = false  -- Unlock input after updating
end

-- Register functions and start the loop
register_function("process_events", process_events)
register_function("update_position", update_position)
register_function("draw", draw_game)
start_main_loop()

In this script, we:

Trigger Event Registration: The register_event_handler function binds the provided function to the 'on_keydown' event. This means the function will be called whenever a key is pressed.

Create Input Locking: The input_locked variable ensures that input is only processed once per update cycle, preventing multiple direction changes in a single frame.

Define Key Mapping: The key_map table maps key codes to direction vectors. For example, pressing the right arrow key (K_RIGHT) sets the direction to {1, 0}.

Define Direction Changes: The function checks if the new direction is not directly opposite to the current direction to prevent the snake from reversing. If valid, it updates the direction and locks the input until the next update.