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