https://healeycodes.com/building-game-prototypes-with-love * Andrew Healey * Articles * Projects * Notes * About * GitHub * Twitter * RSS Building Game Prototypes with LOVE Dec 2024 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). Over the winter break, I spent some time building game prototypes with LOVE -- 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. 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. I found LOVE'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). This snippet allows a player to move a 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 were more fleshed out than this, this snippet captures the essence of LOVE. Chess UI 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). The UIs of the major chess players (chess.com, 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. I wanted to build a riff on chess puzzles but first I needed to get a baseline chess UI working. This was my first LOVE program, and it took me around two hours. To capture mouse input, I used a mixture of LOVE's callback functions (love.mousereleased for the end of a drag, love.mousepressed to move a piece with two clicks). I used love.mouse.getPosition() in order to render pieces while they were being 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 built UIs with many libraries over the years. The most comparable experience to using LOVE is perhaps the browser's Canvas API. I find LOVE 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 LOVE would be a good choice. One of the reasons that makes LOVE such a powerful solution is that LLMs have an easy time generating and analyzing the code required to build prototypes with LOVE. The API is well-known (or can be communicated with very brief docstrings) and the rest of the code required is generic UI math. 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. I hadn't used LLMs in visual projects before and I was surprised at how closelyclaude-3.5-sonnet and gpt-4o were able to get to my prompts (via Cursor). Even though LOVE 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. 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. -- 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 that I've been thinking about recently is 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. 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. This prototype took me three hours. 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. 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. 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). When it comes to the simple stuff, like the health and mana bars, LOVE 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 the hand when dropped) weren't too difficult to build once I had 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 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. Where I Go From Here 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 LOVE. 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. My loose project plan for 2025 looks something like this: * Design a game with notebook/pen * Create the game out of paper and play the prototype with my wife * Build out a basic MVP (without any art) * Playtest with friends * Iterate/more playtesting * Create the art * ??? * Ship I don't expect my prototype code to be overly useful but it's open source nonetheless! --------------------------------------------------------------------- Subscribe to be notified (somewhat irregularly) of my new posts. [ ][Subscribe] - Compiling Lisp to Bytecode and Running It