Skip to content

Commit ccf6d25

Browse files
authored
Merge pull request #53 from healeycodes/post/love-game-prototypes
add love game prototypes post
2 parents 3fce582 + 565302c commit ccf6d25

File tree

7 files changed

+252
-3
lines changed

7 files changed

+252
-3
lines changed

components/code.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import Prism from "prism-react-renderer/prism";
44
(typeof global !== "undefined" ? global : window).Prism = Prism;
55
require("prismjs/components/prism-rust")
6+
require("prismjs/components/prism-lua")
7+
require("prismjs/components/prism-lisp")
68
// --
79

810
import Highlight, { defaultProps } from "prism-react-renderer";

data/projects.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ export default [
102102
"Generate or convert random bytes into passphrases. A Rust port of niceware.",
103103
to: "/porting-niceware-to-rust",
104104
},
105+
{
106+
name: "love-game-prototypes",
107+
link: "https://github.com/healeycodes/love-game-protoypes",
108+
desc: "Some game prototypes made with LÖVE.",
109+
to: "/building-game-prototypes-with-love",
110+
},
105111
{
106112
name: "dm-multiplayer",
107113
link: "https://github.com/healeycodes/dm-multiplayer",

pages/[id].tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,17 @@ export async function getStaticProps({ params }) {
3636
const getVideoDimensions = async (filePath): Promise<{ width: number, height: number }> => {
3737
return new Promise((resolve, reject) => {
3838
const mp4boxFile = mp4box.createFile();
39-
mp4boxFile.onReady = (info) => {
40-
resolve({ width: info.tracks[0].video.width, height: info.tracks[0].video.height });
41-
};
4239
fs.readFile(filePath, (err, data) => {
4340
if (err) { throw err };
4441
const buf = new Uint8Array(data).buffer
4542
// @ts-ignore
4643
buf.fileStart = 0
44+
45+
mp4boxFile.onError = reject;
46+
mp4boxFile.onReady = (info) => {
47+
const videoTracks = info.tracks.filter(track => track.name === 'VideoHandler');
48+
resolve({ width: Math.max(...videoTracks.map(t => t.video.width)), height: Math.max(...videoTracks.map(t => t.video.height)) });
49+
};
4750
mp4boxFile.appendBuffer(buf);
4851
mp4boxFile.flush();
4952
});
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
---
2+
title: "Building Game Prototypes with LÖVE"
3+
date: "2024-12-31"
4+
tags: ["lua"]
5+
description: "Chess, card games, and Lua."
6+
---
7+
8+
One of my goals for 2025 is to build a complete game. *Complete* as in, you can buy it on Steam or the App Store for $2.99 or so. I've made little games before but completing and shipping a game would probably be my largest side project yet (aside from this blog).
9+
10+
Over the winter break, I spent some time building game prototypes with [LÖVE](https://love2d.org/) — a framework for making 2D games in Lua. My goal was to research which game making tools fit my skillset, and to find where my strengths lie so that I can be efficient with my time in 2025.
11+
12+
I had written around 200LOC of Lua before working on these prototypes but I didn't have any issues picking up the rest of the syntax that I needed.
13+
14+
I found LÖVE's API to be simple and powerful. One of the benefits of using a *framework* over a game engine is that I can show you a complete example with 10LOC (as opposed to a game engine, where I would need to define scene objects, attach scripts, and so on).
15+
16+
This snippet allows a player to move a square across the screen.
17+
18+
```lua
19+
x = 100
20+
21+
-- update the state of the game every frame
22+
---@param dt number time since the last update in seconds
23+
function love.update(dt)
24+
if love.keyboard.isDown('space') then
25+
x = x + 200 * dt
26+
end
27+
end
28+
29+
-- draw on the screen every frame
30+
function love.draw()
31+
love.graphics.setColor(1, 1, 1)
32+
love.graphics.rectangle('fill', x, 100, 50, 50)
33+
end
34+
```
35+
36+
While my prototypes were more fleshed out than this, this snippet captures the essence of LÖVE.
37+
38+
## Chess UI
39+
40+
I return to chess every winter. Playing, trying to improve, and also taking on chess-related projects (around this time four years ago, I built [a chess engine](https://healeycodes.com/building-my-own-chess-engine)).
41+
42+
The UIs of the major chess players ([chess.com](http://chess.com), [lichess.org](http://lichess.org)) are incredibly well thought-out. A chess UI may seem like a simple problem but when I started stepping through the state transitions, I came to realize how beautifully it all fits together. The post-game analysis UI on lichess.org is particularly good.
43+
44+
I wanted to build a riff on chess puzzles but first I needed to get a baseline chess UI working. This was my first LÖVE program, and it took me around two hours.
45+
46+
![Basic chess board UI. Moving a bishop between valid squares.](chess-ui.mp4)
47+
48+
To capture mouse input, I used a mixture of LÖVE's callback functions (`love.mousereleased` for the end of a drag, `love.mousepressed` to move a piece with two clicks).
49+
50+
I used `love.mouse.getPosition()` in order to render pieces while they were being dragged.
51+
52+
```lua
53+
local pieceImage = love.graphics.newImage("assets/chess_" .. piece.name .. ".png")
54+
55+
-- ..
56+
57+
-- draw dragged piece at cursor position
58+
if piece.dragging then
59+
local mouseX, mouseY = love.mouse.getPosition()
60+
61+
-- center the piece on cursor
62+
local floatingX = mouseX - (pieceImage:getWidth() * scale) / 2
63+
local floatingY = mouseY - (pieceImage:getHeight() * scale) / 2
64+
65+
-- draw the floating piece with correct color
66+
if piece.color == "white" then
67+
love.graphics.setColor(1, 1, 1)
68+
else
69+
love.graphics.setColor(0.2, 0.2, 0.2)
70+
end
71+
love.graphics.draw(pieceImage, floatingX, floatingY, 0, scale, scale)
72+
end
73+
```
74+
75+
I've built UIs with many libraries over the years. The most comparable experience to using LÖVE is perhaps the browser's [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). I find LÖVE to be the best solution for prototyping free-form UIs with code. I say *free-form* because if I needed something with inputs and buttons then I don't think LÖVE would be a good choice.
76+
77+
One of the reasons that makes LÖVE such a powerful solution is that LLMs have an easy time generating and analyzing the code required to build prototypes with LÖVE. The API is well-known (or can be communicated with very brief docstrings) and the rest of the code required is generic UI math.
78+
79+
This is opposed to Godot Engine's GDScript which LLMs seemed to struggle with out-of-the-box. I imagine this could be improved with things like: fine-tuning, RAG (Retrieval-Augmented Generation), or few-shot prompting — but I didn't explore this further.
80+
81+
I hadn't used LLMs in visual projects before and I was surprised at how closely`claude-3.5-sonnet` and `gpt-4o` were able to get to my prompts (via [Cursor](https://www.cursor.com/)).
82+
83+
Even though LÖVE programs open very fast, I still missed the hot reloading you get when working on browser UIs. On a larger project, I would probably invest some time into building a debug view and/or hot reloading of UI config.
84+
85+
I struggled a little bit with my separation of UI logic vs. application logic. I didn't feel like I ended up with a particularly clean separation but it was productive to work with. You can see how I consumed my “piece API” in the excerpt below.
86+
87+
```lua
88+
-- called when a mouse button is pressed
89+
---@param x number x coordinate of the mouse
90+
---@param y number y coordinate of the mouse
91+
function love.mousepressed(x, y, button)
92+
local result = xyToGame(x, y)
93+
94+
-- check if we've clicked on a valid square
95+
if result.square then
96+
for _, piece in ipairs(pieces) do
97+
98+
-- if we have a piece clicked and it's a valid square, move it
99+
if piece.clicked and piece:validSquare(result.square) then
100+
piece:move(result.square)
101+
return
102+
end
103+
end
104+
end
105+
106+
-- check if we've clicked on a piece
107+
if result.piece then
108+
result.piece:click(x, y)
109+
result.piece:drag()
110+
return
111+
end
112+
113+
-- otherwise, unclick all pieces
114+
for _, piece in ipairs(pieces) do
115+
piece:unclick()
116+
end
117+
end
118+
```
119+
120+
## Card Game UI
121+
122+
Another UI that I've been thinking about recently is [Hearthstone](https://en.wikipedia.org/wiki/Hearthstone) which I played for around a year after its release. It was my first experience with a competitive card game and I had a ton of fun with it.
123+
124+
Card games seem to exist in a sweet spot when it comes to implementation complexity. The bulk of the work seems to be planning and game design. As opposed to, say, 3D games where a significant amount of time is required to create the art and game world. My personal feeling is that I could build an already-planned card game MVP in about a month.
125+
126+
This prototype took me three hours.
127+
128+
![Card game UI. Hovering cards, dragging them, and playing them on the board.](card-ui.mp4)
129+
130+
Compared to the chess UI, this card game prototype required a little over double the LOC. I also faced some of my first challenges when it came to rendering the smooth card interaction animations.
131+
132+
I would usually avoid adding animations to a prototype but they are the core of a good-feeling card game so I brought them forwards into the prototype stage.
133+
134+
Similar to the chess UI, LLMs were able to help with some of the simple scaffolding work like getting boxes and text drawn in the right place, and collecting some scattered state into two groups of configuration (game config, and game state).
135+
136+
When it comes to the simple stuff, like the health and mana bars, LÖVE really shines.
137+
138+
```lua
139+
local function drawResourceBar(x, y, currentValue, maxValue, color)
140+
141+
-- background
142+
love.graphics.setColor(0.2, 0.2, 0.2, 0.8)
143+
love.graphics.rectangle("fill", x, y, Config.resources.barWidth, Config.resources.barHeight)
144+
145+
-- fill
146+
local fillWidth = (currentValue / maxValue) * Config.resources.barWidth
147+
love.graphics.setColor(color[1], color[2], color[3], 0.8)
148+
love.graphics.rectangle("fill", x, y, fillWidth, Config.resources.barHeight)
149+
150+
-- border
151+
love.graphics.setColor(0.3, 0.3, 0.3, 1)
152+
love.graphics.setLineWidth(Config.resources.border)
153+
love.graphics.rectangle("line", x, y, Config.resources.barWidth, Config.resources.barHeight)
154+
155+
-- value text
156+
love.graphics.setColor(1, 1, 1)
157+
local font = love.graphics.newFont(12)
158+
love.graphics.setFont(font)
159+
local text = string.format("%d/%d", currentValue, maxValue)
160+
local textWidth = font:getWidth(text)
161+
local textHeight = font:getHeight()
162+
love.graphics.print(text,
163+
x + Config.resources.barWidth/2 - textWidth/2,
164+
y + Config.resources.barHeight/2 - textHeight/2
165+
)
166+
end
167+
168+
local function drawResourceBars(resources, isOpponent)
169+
local margin = 20
170+
local y = isOpponent and margin or
171+
love.graphics.getHeight() - margin - Config.resources.barHeight * 2 - Config.resources.spacing
172+
173+
drawResourceBar(margin, y, resources.health, Config.resources.maxHealth, {0.8, 0.2, 0.2})
174+
drawResourceBar(margin, y + Config.resources.barHeight + Config.resources.spacing,
175+
resources.mana, resources.maxMana, {0.2, 0.2, 0.8})
176+
end
177+
```
178+
179+
![Close-up of card animations.](card-ui-animations.mp4)
180+
181+
The animations of the cards (rising/growing during hover, falling back to the hand when dropped) weren't too difficult to build once I had defined my requirements.
182+
183+
```lua
184+
-- update the state of the game every frame
185+
---@param dt number time since the last update in seconds
186+
function love.update(dt)
187+
188+
-- ..
189+
190+
-- update card animations
191+
for i = 1, #State.cards do
192+
local card = State.cards[i]
193+
if i == State.hoveredCard and not State.draggedCard then
194+
updateCardAnimation(card, Config.cards.hoverRise, Config.cards.hoverScale, dt)
195+
else
196+
updateCardAnimation(card, 0, 1, dt)
197+
end
198+
updateCardDrag(card, dt)
199+
end
200+
end
201+
202+
-- lerp card towards a target rise and target scale
203+
local function updateCardAnimation(card, targetRise, targetScale, dt)
204+
local speed = 10
205+
card.currentRise = card.currentRise + (targetRise - card.currentRise) * dt * speed
206+
card.currentScale = card.currentScale + (targetScale - card.currentScale) * dt * speed
207+
end
208+
209+
-- lerp dragged cards
210+
local function updateCardDrag(card, dt)
211+
if not State.draggedCard then
212+
local speed = 10
213+
card.dragOffset.x = card.dragOffset.x + (0 - card.dragOffset.x) * dt * speed
214+
card.dragOffset.y = card.dragOffset.y + (0 - card.dragOffset.y) * dt * speed
215+
end
216+
end
217+
```
218+
219+
The above code animates my cards by smoothly transitioning their rise/scale properties between target values. A classic example of linear interpolation (lerping) where the current values are gradually moved toward target values based on elapsed time and a speed multiplier.
220+
221+
## Where I Go From Here
222+
223+
After building out these prototypes (as well as some other small ones not covered here), I have a pretty good grasp on the kind of projects that would be productive for me to build with LÖVE.
224+
225+
I also spent some time playing with the Godot Engine but haven't written up my notes yet. The TL;DR is something like: if I need game engine features (very busy world, complex entity interactions, physics beyond the basics) I would reach for Godot.
226+
227+
My loose project plan for 2025 looks something like this:
228+
229+
- Design a game with notebook/pen
230+
- Create the game out of paper and play the prototype with my wife
231+
- Build out a basic MVP (without any art)
232+
- Playtest with friends
233+
- Iterate/more playtesting
234+
- Create the art
235+
- ???
236+
- Ship
237+
238+
I don't expect my prototype code to be overly useful but [it's open source](https://github.com/healeycodes/love-game-protoypes) nonetheless!
Binary file not shown.
1.91 MB
Binary file not shown.
429 KB
Binary file not shown.

0 commit comments

Comments
 (0)