I Built a GUI in Lua with Limekit. Here's My Take.
Ready to build a GUI in Lua? This step-by-step tutorial shows you how to create a responsive, functional UI from scratch using the Limekit library.
Alex Carter
Indie game developer and Lua enthusiast passionate about building efficient, lightweight tools.
If you've ever worked with Lua, you know it's a wonderfully lightweight and powerful scripting language. It’s a joy to embed in applications, especially for game development with frameworks like LÖVE 2D or Defold. But when it comes to building a Graphical User Interface (GUI), things can get… complicated. Traditional GUI toolkits can feel heavy, complex, or just plain awkward to integrate.
That’s the exact wall I hit a few weeks ago. I needed a simple, data-driven UI for a small game development tool I was prototyping. After wrestling with a few options, I stumbled upon Limekit, an immediate mode GUI library for Lua. It was a game-changer. It’s simple, intuitive, and lets you build UIs with a surprisingly small amount of code.
So, I decided to share my experience. In this post, I'll walk you through exactly how I built a functional GUI in Lua with Limekit, from the ground up.
What's the Deal with Immediate Mode?
Before we dive in, let's quickly touch on what makes Limekit different. It uses an “immediate mode” paradigm. If you've ever used libraries like Dear ImGui, you'll feel right at home.
Here’s the gist:
- Retained Mode (The Traditional Way): You create UI elements (buttons, windows) as objects. You set their properties, add them to a scene graph, and register event handlers (like
button.onClick = function() ... end
). The library “retains” this state and manages drawing and events for you. Think HTML DOM or Qt. - Immediate Mode (The Limekit Way): You don’t create persistent UI objects. Instead, you call functions to draw widgets every single frame. A button isn't an object; it's a function call:
if lk.Button("Click Me") then ... end
. The function both draws the button and returnstrue
for the one frame it was clicked. The UI is a direct, immediate result of your code and application state.
This approach is incredibly simple and powerful for tools, debug menus, and games, as your UI code lives right alongside your application logic and naturally reflects its state.
Getting Started: Your First Limekit Window
Let's get our hands dirty. For this tutorial, I’m using LÖVE 2D, a fantastic 2D game framework for Lua. Limekit integrates with it seamlessly.
Prerequisites
- Lua: A working Lua installation. LÖVE 2D comes with its own, so you're covered there.
- LÖVE 2D: Download and install the latest version.
- Limekit: It's a single Lua file! Just grab
limekit.lua
from the official GitHub repository.
Project Setup
Create a new folder for your project. Inside, place two files:
main.lua
: This will be our main application file.limekit.lua
: The library file you just downloaded.
That's it! Now, open main.lua
and let's write some code. We'll start with the standard LÖVE 2D structure and initialize Limekit.
-- main.lua
-- Require the limekit library
local lk = require 'limekit'
function love.load()
-- This function runs once at the beginning
-- We don't need to load anything specific for this simple example
end
function love.update(dt)
-- This function runs every frame
-- We pass the delta time to limekit's update function
lk.Update(dt)
end
function love.draw()
-- This function also runs every frame, after update
-- 1. Begin the UI frame
lk.BeginFrame()
-- 2. Define our window and its contents
if lk.BeginWindow("My First Window", { 50, 50, 300, 200 }) then
-- All our widgets will go inside here
lk.Text("Hello, Limekit!")
end
lk.EndWindow()
-- 3. End the frame and draw the UI
lk.EndFrame()
end
To run this, simply drag your project folder onto the LÖVE 2D application executable. You should see a window appear with the title “My First Window” and the text “Hello, Limekit!” inside. You can even drag it around and resize it. Magic!
Notice the pattern: lk.BeginWindow
starts a new window and returns true
if it's visible and not collapsed. We then draw our widgets inside the if
block, and finally call lk.EndWindow
.
The Building Blocks: Adding Widgets
An empty window is cool, but not very useful. Let's add some interactive elements. This is where the power of immediate mode really shines.
First, let's create a table in love.load
to hold our application's state. This is how we'll manage data for our widgets.
-- Add this inside your love.load function
appState = {
name = "World",
showGreeting = false,
brightness = 0.75
}
Capturing User Input
Let's add a text input field to change the name. The lk.InputText
function takes a label and our state table. Limekit handles the rest.
-- Inside your BeginWindow/EndWindow block
-- The key 'name' corresponds to appState.name
lk.InputText("Name", appState, "name")
Run this, and you’ll see a text box. You can type in it, and Limekit automatically updates the appState.name
variable for you. It’s that easy.
Making Things Happen with Buttons
Buttons are the core of interaction. The lk.Button
function returns true
for the single frame it's pressed.
-- Inside your BeginWindow/EndWindow block
if lk.Button("Toggle Greeting") then
-- Invert the boolean value when the button is clicked
appState.showGreeting = not appState.showGreeting
end
-- Conditionally display text based on our state
if appState.showGreeting then
lk.Text("Hello, " .. appState.name .. "!")
end
Now when you click the button, the greeting text will appear or disappear. Notice how the UI is a direct reflection of the appState
table. There are no callbacks or event listeners to manage.
Sliders and Checkboxes for Dynamic Control
Sliders and checkboxes work similarly, by directly manipulating values in our state table.
-- Inside your BeginWindow/EndWindow block
-- A checkbox bound to 'showGreeting'
lk.Checkbox("Show Greeting", appState, "showGreeting")
-- A slider for a float value, from 0.0 to 1.0
-- This will modify appState.brightness
lk.SliderFloat("Brightness", appState, "brightness", 0.0, 1.0)
-- Display the current value of the slider
lk.Text(string.format("Current Brightness: %.2f", appState.brightness))
We now have a checkbox that does the same thing as our button, and a slider that controls a float value. The code is clean, readable, and directly expresses the relationship between the UI and the data.
Putting It All Together: A Simple Color Picker
Let's create a mini-application that uses these concepts to build something practical: a color picker. It will have three sliders for Red, Green, and Blue, and it will display the resulting color.
Here is the complete main.lua
for this example:
-- main.lua (Color Picker Example)
local lk = require 'limekit'
function love.load()
-- State for our color picker app
appState = {
color = { r = 0.2, g = 0.5, b = 0.8 }
}
end
function love.update(dt)
lk.Update(dt)
end
function love.draw()
-- Set the background color based on our slider values
love.graphics.clear(appState.color.r, appState.color.g, appState.color.b)
lk.BeginFrame()
if lk.BeginWindow("Color Picker", { 20, 20, 350, 220 }) then
lk.Text("Adjust the background color:")
lk.Spacing()
-- Sliders for R, G, and B
-- Note: We have to access the nested table for the state
lk.SliderFloat("Red", appState.color, "r", 0.0, 1.0)
lk.SliderFloat("Green", appState.color, "g", 0.0, 1.0)
lk.SliderFloat("Blue", appState.color, "b", 0.0, 1.0)
lk.Spacing()
-- Display the current RGB values
local r = math.floor(appState.color.r * 255)
local g = math.floor(appState.color.g * 255)
local b = math.floor(appState.color.b * 255)
lk.Text(string.format("RGB: (%d, %d, %d)", r, g, b))
end
lk.EndWindow()
lk.EndFrame()
end
Run this code, and you'll have a fully functional color picker that changes the application's background color in real-time. This example perfectly illustrates the immediate mode philosophy: the entire application, including the background, is redrawn every frame as a function of the appState
table, which is manipulated directly by the UI code.
Why Limekit Was the Right Choice
After building my tool with Limekit, I'm convinced it's a fantastic choice for a specific set of problems. Here's why you might love it too:
- Incredible Simplicity: The API is small and easy to learn. You can be productive within minutes.
- Rapid Prototyping: Need to visualize some data or add a debug control? You can do it in a few lines of code without refactoring your whole application.
- Zero Boilerplate: No complex object hierarchies, no event listeners, no state synchronization issues. Your UI code is as simple as it gets.
- Perfect for Dev Tools: It’s ideal for building level editors, debug overlays, and other internal tools where iteration speed is more important than complex, pixel-perfect layouts.
Of course, it's not a silver bullet. For a complex, standalone desktop application with demanding layout requirements, a traditional retained-mode framework might be a better fit. But for the vast world of game development tools and simple apps in Lua, Limekit is an absolute gem.
Conclusion
Building a GUI in Lua doesn't have to be a chore. With a library like Limekit, you can create powerful, interactive user interfaces that are easy to reason about and a pleasure to write. The immediate mode paradigm strips away layers of abstraction, putting you in direct control and allowing you to build at the speed of thought.
If you're a Lua developer, I highly encourage you to give Limekit a try for your next project. What will you build?