Software & Apps

Building Game Prototypes with LÖVE – Andrew Healey

One of my goals for 2025 is to make a complete game. unabridged like, you can buy it on Steam or the App Store for $2.99 ​​or more. I’ve made quite a few games before but completing and shipping a game is probably my biggest side project (besides this blog).

During the winter, I spent some time making game prototypes lion – a framework for creating 2D games in Lua. My goal is to research which game development tools fit my skill set, and to find out where my strengths lie so I can be efficient with my time in 2025.

I wrote about 200LOC in Lua before working on these prototypes but I didn’t have any issues getting the rest of the syntax I needed.

I found LÖVE’s API simple and powerful. One of the benefits of using a BUILDING in a game engine so I can show you a complete example of 10LOC (as opposed to a game engine, where I have to define scene objects, include scripts, and others).

This snippet allows a player to move one square across the screen.

x = 100

-- update the state of the game every frame

---@param dt number time since the last update in seconds

function love.update(dt)

if love.keyboard.isDown('space') then

x = x + 200 * dt

end

end

-- draw on the screen every frame

function love.draw()

love.graphics.setColor(1, 1, 1)

love.graphics.rectangle('fill', x, 100, 50, 50)

end

While my prototypes are much more than this, this snippet captures the essence of LÖVE.

Chess UI

I return to chess every winter. Playing, trying to improve, and also taking on chess-related projects (at this time four years ago, I was building a chess engine).

The UIs of the great chess players (chess.com, lichess.org) is very well thought out. A chess UI seems like a simple problem but when I started going through the state transitions, I realized how beautifully it all came together. The post-game analysis UI on lichess.org is particularly good.

I want to make a riff on chess puzzles but first I need to get a baseline chess UI working. This is my first LÖVE program, and it took me about two hours.

To get the mouse input, I use a mixed callback function in LÖVE (love.mousereleased for the end of a drag, love.mousepressed to move a piece in two clicks).

I use love.mouse.getPosition() to give pieces as they are dragged.

local pieceImage = love.graphics.newImage("assets/chess_" .. piece.name .. ".png")

-- ..

-- draw dragged piece at cursor position

if piece.dragging then

local mouseX, mouseY = love.mouse.getPosition()

-- center the piece on cursor

local floatingX = mouseX - (pieceImage:getWidth() * scale) / 2

local floatingY = mouseY - (pieceImage:getHeight() * scale) / 2

-- draw the floating piece with correct color

if piece.color == "white" then

love.graphics.setColor(1, 1, 1)

else

love.graphics.setColor(0.2, 0.2, 0.2)

end

love.graphics.draw(pieceImage, floatingX, floatingY, 0, scale, scale)

end

I’ve been building UIs with many libraries over the years. The most similar experience of using LÖVE is probably in the browser Canvas API. I found LÖVE to be the best solution for prototyping free-form UIs with code. I said free form because if I need something with inputs and buttons then I don’t think LÖVE is a good choice.

One of the reasons that makes LÖVE such a powerful solution is that LLMs have an easy ​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​front The API is well known (or can be communicated in very short docstrings) and the rest of the code required is generic UI math.

This is in contrast to Godot Engine’s GDScript which seems to struggle with LLMs out of the box. I thought it could be improved with things like: healing, RAG (Retrieval-Augmented Generation), or some shot stimulation – but I haven’t checked it yet.

I have never used LLMs in visual projects before and I was surprised at how close it wasclaude-3.5-sonnet and gpt-4o managed to get my prompts (via DISPLAY).

Although LÖVE programs open very quickly, I still miss the hot reload you get when working with browser UIs. In a larger project, I might invest some time in building a debug view and/or hot reloading the UI config.

I’m struggling a bit with my separation of UI logic vs. application logic. I don’t feel like I ended up with a particularly clean breakup but it was productive to work with. You can see how I use my “slice API” in the excerpt below.

-- called when a mouse button is pressed

---@param x number x coordinate of the mouse

---@param y number y coordinate of the mouse

function love.mousepressed(x, y, button)

local result = xyToGame(x, y)

-- check if we've clicked on a valid square

if result.square then

for _, piece in ipairs(pieces) do

-- if we have a piece clicked and it's a valid square, move it

if piece.clicked and piece:validSquare(result.square) then

piece:move(result.square)

return

end

end

end

-- check if we've clicked on a piece

if result.piece then

result.piece:click(x, y)

result.piece:drag()

return

end

-- otherwise, unclick all pieces

for _, piece in ipairs(pieces) do

piece:unclick()

end

end

Card Game UI

Another UI I’ve been thinking about recently is Hearthstone which I played about a year after its release. This was my first experience with a competitive card game and I had a ton of fun with it.

Card games seem to exist in a sweet spot when it comes to implementation complexity. Most of the work seems to be planning a game plan. As opposed to, say, 3D games where it takes a lot of time to create the art and game world. My personal feeling is that I can make a pre-planned card game MVP in about a month.

This prototype took me three hours.

Compared to the chess UI, this card game prototype requires a little double the LOC. I also faced some of my first challenges when it came to rendering smooth card interaction animations.

I usually avoid adding animations to a prototype but they are the core of a good feeling card game so I brought them forward to the prototype stage.

Like the chess UI, LLMs help with some simple scaffolding work like getting boxes and text drawn in the right place, and collecting some scattered state into two configuration groups (game config, and game state).

When it comes to the simple stuff, like health and mana bars, LÖVE really shines.

local function drawResourceBar(x, y, currentValue, maxValue, color)

-- background

love.graphics.setColor(0.2, 0.2, 0.2, 0.8)

love.graphics.rectangle("fill", x, y, Config.resources.barWidth, Config.resources.barHeight)

-- fill

local fillWidth = (currentValue / maxValue) * Config.resources.barWidth

love.graphics.setColor(color(1), color(2), color(3), 0.8)

love.graphics.rectangle("fill", x, y, fillWidth, Config.resources.barHeight)

-- border

love.graphics.setColor(0.3, 0.3, 0.3, 1)

love.graphics.setLineWidth(Config.resources.border)

love.graphics.rectangle("line", x, y, Config.resources.barWidth, Config.resources.barHeight)

-- value text

love.graphics.setColor(1, 1, 1)

local font = love.graphics.newFont(12)

love.graphics.setFont(font)

local text = string.format("%d/%d", currentValue, maxValue)

local textWidth = font:getWidth(text)

local textHeight = font:getHeight()

love.graphics.print(text,

x + Config.resources.barWidth/2 - textWidth/2,

y + Config.resources.barHeight/2 - textHeight/2

)

end

local function drawResourceBars(resources, isOpponent)

local margin = 20

local y = isOpponent and margin or

love.graphics.getHeight() - margin - Config.resources.barHeight * 2 - Config.resources.spacing

drawResourceBar(margin, y, resources.health, Config.resources.maxHealth, {0.8, 0.2, 0.2})

drawResourceBar(margin, y + Config.resources.barHeight + Config.resources.spacing,

resources.mana, resources.maxMana, {0.2, 0.2, 0.8})

end

The animations of the cards (rising/growing during hover, falling back to hand when falling) were not too difficult to create once I defined my requirements.

-- update the state of the game every frame

---@param dt number time since the last update in seconds

function love.update(dt)

-- ..

-- update card animations

for i = 1, #State.cards do

local card = State.cards(i)

if i == State.hoveredCard and not State.draggedCard then

updateCardAnimation(card, Config.cards.hoverRise, Config.cards.hoverScale, dt)

else

updateCardAnimation(card, 0, 1, dt)

end

updateCardDrag(card, dt)

end

end

-- lerp card towards a target rise and target scale

local function updateCardAnimation(card, targetRise, targetScale, dt)

local speed = 10

card.currentRise = card.currentRise + (targetRise - card.currentRise) * dt * speed

card.currentScale = card.currentScale + (targetScale - card.currentScale) * dt * speed

end

-- lerp dragged cards

local function updateCardDrag(card, dt)

if not State.draggedCard then

local speed = 10

card.dragOffset.x = card.dragOffset.x + (0 - card.dragOffset.x) * dt * speed

card.dragOffset.y = card.dragOffset.y + (0 - card.dragOffset.y) * dt * speed

end

end

The code above animates my cards by smoothly transitioning their height/scale properties between target values. A classic example of linear interpolation (lerping) where current values ​​are gradually shifted to target values ​​based on elapsed time and a speed multiplier.

Where Do I Go From Here?

After making these prototypes (as well as other small ones not covered here), I have a good understanding of the kind of projects that will be productive for me to build with LÖVE.

I’ve also been playing Godot Engine for a while but I haven’t written my notes yet. The TL; DR is something like: when I need parts of the game engine (very busy world, complex entity interaction, physics beyond the basics) I reach for Godot.

My loose project plan for 2025 is as follows:

  • Design a game using a notebook/pen
  • Making the game out of paper and playing the prototype with my wife
  • Create a basic MVP (without any art)
  • Playtest with friends
  • Iteration / more playtesting
  • Making art
  • ???
  • SHIP

I didn’t expect my prototype code to be too useful but it’s open source anyway!

2024-12-31 21:58:00

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button