stil — essentialist game engine

Hello y’all. After months of introspection on my game creation process — that I chose to omit here, but please indicate if you find the meta stuff interesting — I decided to go a very radical route and design a very light game engine.

stil — git repository

It encourages linear game programming instead of loop-based programming, very fast iteration process and simplistic aesthetics. The goal is both to use this for prototypes and “finished products”.

The name is a homage to De Stijl. The tech stack is Zig stable, SDL.zig and Ziglua for Luau scripting. The resulting statically linked binary for Windows 2.9 Mo, with the possibility to embed the game script. The API is inspired by my past experience prototyping games in Basic Casio. License tbd, will probably be MIT.

Here’s a sample project — a box cursor moving around, placing and removing eXes.

x, y = 2, 1

resize(8 * 2 + 1, 8) -- sets display dimensions, in chars

while true do
	locate(x - 1, y, "[")
	locate(x + 1, y, "]")
	local k = getkey() -- refresh the screen and wait for input
	locate(x - 1, y, " ")
	locate(x + 1, y, " ")

	--movement
	    if k == 4  then x -= 2
	elseif k == 7  then x += 2
	elseif k == 26 then y -= 1
	elseif k == 22 then y += 1
	elseif k == 44 then locate(x, y, peek(x, y) == "x" and " " or "x")
	end

	--limit to screen
	x = math.clamp(x, 2, dwidth - 1)
	y = math.clamp(y, 1, dheight)
end

And a screenshot.
2025-04-25_01-04-1745537301

Hi, sounds neat!! It’d be super interesting to read about all the meta stuff like your design goals for the engine and how they got formed, what frustrations from using other tools you want to address, what you imagine your ideal workflow to be, etc

Can you describe some more what you mean by linear vs loop-based game programming?

Hey, thank you for the interest!

The main design goals for the engine are

  1. To reduce friction. By scripting in a single file, with no needs for dependencies or even graphical assets, I can get started on a game or prototype simply by creating a new file and running it with stil project.lua.
  2. To encourage creativity. Game engines present fairly opinionated structures, and hence shape the way games need to behave. stil goes to an extreme, by making completely impossible the creation of traditional real time games ; ie. getkey being blocking, any visual update needs user input before proceeding.
  3. To challenge the perception of what a game is meant to be. So far, I’m using this engine to write small interactive experiments, some generative poetry tools and a few anti-games. All took less than an average coding session to get from concept to reality. Iteration is redundant when games are so fast to create (iterative game design is a commercial concept).
  4. To share ideas. Editing a single line in src/main.zig to indicate the path of a .lua file and running zig build creates a static executable that’s under 3 Mo. As a demonstration, here’s a small minesweeper homage I made as a reflection on the concept of win conditions: stil.zip (1.1 MB). I want to encourage sharing little things that do not require installation, simply download and run. (Sadly, this forum doesn’t authorize .exe files, but you can imagine this is pretty handy in most chatting softwares to demonstrate a small concept.)

By loop-based programming, I wanted to talk about the way most game engines assume games need to work at scale. Typically, you create a game loop that draws, updates, handles inputs and do some other stuff. Eg., in GameMakerStudio 2, if you want to create a menu, you need to create an object, write some initialization code, some draw code, and some update logic code. All runs in a loop, and is executed every frame. This is very sane at scale (and for real time games), but requires a lot of work and logical states for every single small element. By moving away from this model, I want to encourage writing small one-off interactions, such as input prompts or dialogs, without the need to climb in abstractions.

As a small example, imagine you wrote a small RPG and want to add an help menu. In a typical entity-based game engine, you could do it in such a way:

-- main loop
function update()
  if key_pressed("h") then
    pause_the_game_somehow()
    spawn_entity(HelpMenu)
    return
  end
  -- other logic stuff
end

-- help menu loop
function HelpMenu:update()
  if key_pressed("any") then
    unpause_the_game_somehow()
    destroy(self)
  end
end

function HelpMenu:draw()
  draw_the_menu_somehow()
end

in my eyes, this is a lot of work anytime you want to add such small interactions. In contrast, this is how you could do it in stil:

-- title screen
locate(1, 1, "super fun game")
getkey()
clear()

-- explicit main loop
while true
  local s, c = getkey() -- getkey returns two values, scancode and keycode
  if c == string.byte'h' then
    -- display help
    locate(1, 1, "space to jump")
    locate(1, 2, "arrows to move")
    continue
  end
  -- do gamy things...
end

I hope this made some sense, I’d happily try to explain in some other ways if this was unclear.

how is this handling the ‘waiting on input’ - the example exe I’m getting seems to have really delayed input on windows and kinda crawls on my system

Its handled by a fairly standard event fetching loop.
https://kdx.re/cgit/stil/tree/src/view.zig?id=2f49052fdf3b4aa0031b6cbf6c24ed6f50502247#n147
If it’s a crawl on your system, I’m worried the std.Thread.sleep might not behave the way I want it to — on Linux it behaves like sleep(0) and releases the thread to the scheduler, which avoids a very intensive infinite loop. Before it gets asked, the rendering needs to be done after each event pull in case the windows was moved or resized as it is hard to detect on Windows specifically.

Could you try this build? I enabled VSync, hopefully it should help.
stil.zip (1.1 MB)

This is definitely much smoother! Yeah its maybe either how sleep works or how the event pipe works differently on win

Thank you! As subsequent decisions, I also transitioned from Zig’s standard sleep to SDL_Delay, with 10ms sleep after each draw. Better safe than sorry.

I also spent some time working with the build system in order to export release build more easily. The following command creates a demo.exe embedding src/lose.lua in zig-out/bin — Still pretty impressed than the resulting codebase is still under 400 lines of code.

zig build -Dembed=lose.lua -Dname=demo -Doptimize=ReleaseSmall -Dtarget=x86_64-windows

After almost a week, I still find it very fun to use.

So I am wrapping my head further around this linear vs. looping development, and of course the “blocking” input function. If this helps at all, @ptrV

Because at first when reviewing the Vsync-ish stuff I thought “why not have the lua code setup like so”

-- game loop updates only on player input
function step(s, c)
  if c == string.byte'h' then
    -- display help
    locate(1, 1, "space to jump")
    locate(1, 2, "arrows to move")
    return
  end
  -- do gamy things
end

In a case like the above, the underlying zig code would be responsible for sending a stream of inputs one step at a time into the lua vm. this may be workable for a game, but it puts the lua code in a state responsible for, each call, to:

  • determine the current state of the game
  • carry out events according to that state
  • determine if the game is completed or being exited

so, rather than that, the point of making getKey() a callable, blocking function is to give you the ability to control when the state progresses, rather than waiting on a presumed stream of input.

it allows for a bit more expressive call-and-response type logic:

--paraphrasing slightly:

if getkey() == 'h' then
  print "in this game you [c]ollect coins and run from [g]hosts"
  print "[<]: return"

  if getkey() == 'c' then
    print "collect coins with the [SPACE] key"
      if getkey() ==' 'then
        print "space space space is the place"
      end
  end
  if getKey() == 'g' then
    print "ghosts are awful stuff!"
  end
end

and i think the format of writing a ‘tutorial’ here is a little misleading, but the idea is that progressing through the game follows a much more ‘treelike’ structure, where each point is explicitly defining what the player can do next rather than waiting on things to happen. the game can be structured far close to something like an actual ‘dialogue’ with the player this way where they can navigate direct sequences of events. hopefully im seeing the right things here

based on the sorta code sample above it makes me think maybe a pythonic syntax might be better suited ahaha

Thank you for the explanations! In recent years I’ve become extremely “Tools Shape the Art”-pilled myself, and this looks like a very nifty tool to make the art that you want

While working on an engine i’ve been thinking about game architecture in terms i call in my head “top-down” and “bottom-up”. Roughly speaking, where in the hierarchy the smarts are at, what level of abstraction is responsible for executing “game logic”.

For example, a menu with buttons. In a top-down paradigm, a top-level abstraction Menu spawns needed Buttons and listens for inputs. When receiving directional inputs it moves selection between buttons. When it receives a confirm input, it checks what button is currently selected, and executes appropriate logic for that button. Button is dumb, smarts are up top, in the Menu.

In a bottom-up paradigm, Menu creates Buttons, tells one button it is initially selected. But now Button itself is listening for inputs and knows what logic it is supposed to execute. On directional inputs it finds a button next to it and tells that one to be selected instead. Once confirm input is received, currently selected button executes its logic. Menu is now dumb, smarts are at the bottom, in the Button.

I find top-down systems much faster and easier to code and debug, but they are rigid, and not very suitable for systems-y dynamic gameplay with lots of moving parts. While bottom-up systems in contrast can be very flexible and dynamic, but are a bother to implement and debug.

This here would be an extreme example of top-down architecture, where it doesn’t even listen to input from some higher level system but decides itself when to receive it. And so it has the top-down benefits of being extremely simple and fast to code with. At the cost of dynamism, which was unwanted anyway

Yes, you got it right! Making the code behave the way you want it to requires a couple helper functions, but the underlying line of thought is correct:

function gc()
	local _, c = getkey()
	if c >= string.byte' ' and c <= string.byte'~' then
		return string.char(c)
	end
end

_sayY = 1
function say(s)
	locate(1, _sayY, s)
	_sayY += 1
end

resize(80, 24)
say 'hello, Hi'
if gc() == 'h' then
	say 'in this game you Collect coins and run from Ghosts'
	local c = gc()
	if c == 'c' then
		say 'collect coins with SPACE'
		if gc() == ' ' then
			say 'space space space is the place'
		end
	elseif c == 'g' then
		say 'ghosts are awful stuff!'
	end
end
gc()

It’s a bit inconvenient to have to write a wrapper for getkey just to receive a string value, so I’ll consider the possibility of changing the behavior of getkey and/or add getchar.

And I do agree on the fact Lua syntax might not be the best for this type of example, but the simplicity of Lua fits pretty well with the engine I feel like. But I’m open with experimenting with other scripting languages, maybe give a shot to MicroPython.

This is a good analysis! Going to an extreme is absolutely part of the experiment. Making more human/creative oriented tools instead of ones catered to commercial-scale project is a big part too. Encouraging improvisation in the game space is something I care about a lot, as this is a medium where most creators seem to be focused on making large scale masterpieces, like if we all needed to come out with a rock opera double album masterpiece to share anything worth of note. But I like games that are more like love songs, improvised jazz pieces, or small paintings. And this is one way for me to understand whats stopping many of us from doing that in the current landscape.