Hello, Natiny

Your first Natiny app needs no project generator and no assets. In a few lines it opens a window, runs a game loop, and draws a moving square.

You can paste this program into the browser sandbox and run it immediately, or save it as data/main.lua in the Lua release bundle.

Lua
local natiny = require("natiny")

assert(natiny.backend.init())

local window = natiny.window.create("Hello, Natiny", 800, 600)
assert(window ~= 0, "failed to create the window")
natiny.window.set_scale_mode(window, natiny.window.SCALE_MODE_HIDPI)

local x = -80

natiny.backend.loop(function(dt)
    x = x + 180 * dt
    if x > natiny.window.get_width(window) then
        x = -80
    end

    natiny.window.bind(window, function()
        natiny.render.clear(0.07, 0.09, 0.13)
        natiny.render.set_color(0.66, 0.76, 0.22, 1.0)
        natiny.render.rectangle(x, 260, 80, 80)
    end)
end)

Run it and a green square will travel across a dark window. That square is a useful first “hello”: it exercises the engine, the window, the frame loop, and 2D drawing without hiding any of them behind setup code.

Build the program in four steps

1. Start the engine

Lua
local natiny = require("natiny")
assert(natiny.backend.init())

require("natiny") returns the engine module. Natiny does not add hidden globals to Lua.

A graphics backend is the platform-specific renderer Natiny uses to talk to the GPU. backend.init chooses the preferred one: Direct3D 12 on Windows, Vulkan on Linux, Metal on macOS, and WebGPU in the browser. assert stops with an error if initialization fails.

2. Create a window with sharp display scaling

Lua
local window = natiny.window.create("Hello, Natiny", 800, 600)
assert(window ~= 0, "failed to create the window")
natiny.window.set_scale_mode(window, natiny.window.SCALE_MODE_HIDPI)

window.create opens a window whose requested size is 800 by 600. In a web build, the same call uses the page's canvas. The returned number is a handle that identifies this window in later calls.

A high-density display can provide more pixels than the requested window size. window.set_scale_mode keeps the 800 by 600 drawing space while using those extra pixels for sharper shapes. The window scaling guide explains all three modes and when to choose each one.

3. Update once per frame

Lua
local x = -80

natiny.backend.loop(function(dt)
    x = x + 180 * dt
    if x > natiny.window.get_width(window) then
        x = -80
    end

    -- draw the frame here
end)

backend.loop calls the function once per frame. dt is the elapsed time in seconds since the previous frame.

The square moves at 180 drawing units per second, not 180 units per frame. Multiplying movement by dt keeps its speed stable on a 60 Hz display, a 144 Hz display, or a slower machine. Once the square leaves the width returned by window.get_width, it starts again just outside the left edge.

4. Draw the frame into the window

Lua
natiny.window.bind(window, function()
    natiny.render.clear(0.07, 0.09, 0.13)
    natiny.render.set_color(0.66, 0.76, 0.22, 1.0)
    natiny.render.rectangle(x, 260, 80, 80)
end)

A render target is the image that receives drawing commands. window.bind makes this window the current render target. Every draw call inside the callback goes into it, and the completed frame is presented when the callback returns.

The drawing calls use a small amount of current state:

  1. clear fills the background;
  2. set_color selects the colour for following draw calls;
  3. rectangle draws at (x, 260) with a size of 80 by 80 drawing units.

Window coordinates begin at the top-left. X grows to the right and Y grows downward.

Run it

Download a Lua bundle and unzip it. The executable looks for data/main.lua beside itself, so a complete project can be this small:

TEXT
my_game/
├── data/
│   └── main.lua
└── natiny.exe

Each platform has a page of its own with the commands for it: Windows, Linux, macOS, Android and Web.

You can also paste this program into the browser sandbox and run it without downloading anything.

Change the first program

Try one small change at a time:

  • change the four values passed to render.set_color;
  • replace rectangle with circle(x, 300, 40);
  • change 180 to control the speed;
  • add a y value and move in two dimensions;
  • draw a second shape after the first one.

You now have the essential game loop: update state, bind a target, draw a frame. Input, textures, models, physics, sound, and custom NSL shaders all build on that same loop.