Usagi Logo: pixel art bunny, Usagi Engine - Rapid 2D Prototyping

Usagi - Simple 2D Game Engine for Rapid Prototyping

Usagi is a 2D game engine for making pixel art games in Lua 5.5. It features live reload, single-command cross-platform export, and a pause menu with input remapping built in.

Usagi is free software made by Brett Chalupa and dedicated to the public domain. Support development of the engine by buying me a coffee.

Links: usagiengine.com, Discord, r/UsagiEngine, Quickstart video, YouTube Playlist

Install

Linux, macOS:

curl -fsSL https://usagiengine.com/install.sh | sh

Windows (PowerShell):

irm https://usagiengine.com/install.ps1 | iex

The installer fetches the latest release, verifies its SHA-256 checksum, installs usagi to ~/.usagi/bin/ (or %USERPROFILE%\.usagi\bin\ on Windows), and adds it to PATH.

Homebrew (package manager):

brew tap brettchalupa/usagi https://codeberg.org/brettchalupa/usagi
brew install usagi

Manual download: Codeberg | GitHub | itch.io

Latest release: v1.1.0.

View the changelog.

Rotating cube demo

Features

Bring your own sound effects, sprite editor, and music tools.

Menu preview showing Continue, Settings, Clear Save Data, Reset Game, and Quit options

Hello, Usagi

Bootstrap a project and start it in dev mode:

usagi init my_game
cd my_game
usagi dev

init writes main.lua (with _init / _update / _draw stubs), .luarc.json for Lua LSP support, .gitignore, meta/usagi.lua (API type stubs), and USAGI.md (a copy of these docs).

Edit main.lua, save, and the running game picks up the change without restarting or losing state. Drawing "Hello, Usagi!" looks like:

function _draw(_dt)
  gfx.clear(gfx.COLOR_BLACK)
  gfx.text("Hello, Usagi!", 10, 10, gfx.COLOR_WHITE)
end

Updating Usagi

Replace the usagi binary with a newer release, or run usagi update to fetch the latest. Then run usagi refresh inside a project to refresh the LSP type stubs and embedded docs (meta/usagi.lua, .luarc.json, USAGI.md). It won't touch main.lua.

Feedback and Issues

Open an issue for feedback, requests, and bugs. Search first to avoid duplicates.

Goals and non-goals

Usagi is for rapid 2D pixel-art prototyping in Lua. It's a great fit if you want to quickly try out an idea, if you're new to game programming, if you've hit Pico-8's token limit, or if you want something simpler than Love2D.

It is not a fantasy console or a Love2D replacement. It doesn't target mobile or VR, and it isn't built for medium-to-large polished games.

Why Lua: small, widely used in game tooling, and powerful enough to stay out of your way.

Project Layout

An Usagi game is either a single .lua file or a directory with a main.lua in it. Additional .lua files anywhere under the project root can be loaded with stock Lua's require. Optional assets live alongside the source code. Here's what a folder structure could look like for a multi-file project:

my_game/
  main.lua           -- required: your game's entry point
  sprites.png        -- optional: 16×16 sprite sheet (PNG with alpha)
  palette.png        -- optional: custom palette (1px tall, one color per pixel)
  font.png           -- optional: custom font (bake with `usagi font bake`)
  enemies.lua        -- optional: require "enemies"
  data/
    level.json       -- optional: JSON data, loadable with `usagi.read_json("level.json")
    dialog.txt       -- optional: text data, loadable with `usagi.read_text("dialog.txt")
  scenes/
    main_menu.lua    -- optional: require "scenes.main_menu" - source code can be in folders
  sfx/               -- optional: .wav files, file stems become sfx names
    jump.wav
    coin.wav
  music/             -- optional: .ogg/.mp3/.wav/.flac, file stems become track names
    overworld.ogg
    boss.ogg
  shaders/           -- optional: post-process GLSL shaders (advanced; see Shaders)
    crt.fs           -- desktop GLSL 330
    crt_es.fs        -- web GLSL ES 100

require "name" resolves to name.lua in the project root, falling back to name/init.lua if that misses. Dotted names (require "world.tiles") become slash-separated paths. The same lookup works inside a fused / exported build, so multi-file projects ship as a single binary or .usagi with no extra config.

Run with:

You can also run Usagi commands without a path to have them run in the current directory, like usagi dev or usagi export.

Lua API

Philosophy: keep it simple, name things clearly, and prefer fixed function signatures.

Style: for Lua, 2 spaces indent with snake_case for locals, function names, and table fields. SCREAMING_SNAKE_CASE for file-scope constants (local TICK = 0.12, gfx.COLOR_*). Cross-frame globals are Capitalized. The canonical game-state container is State, set inside _init. Module imports kept as globals are Player = require("player"). The shipped .luarc.json enables lowercase-global, so any unguarded lowercase assignment at file scope is flagged as an accidental missing local. Engine API (gfx, input, sfx, music, usagi) stays lowercase and is exempt from the lint via meta/usagi.lua.

View the Lua 5.5 docs for full language reference.

Cheatsheet

-- Engine info / config

usagi.GAME_W
usagi.GAME_H
usagi.SPRITE_SIZE
usagi.PLATFORM -- "web" | "macos" | "linux" | "windows" | "unknown"
usagi.IS_DEV
usagi.elapsed
usagi.measure_text(text)
usagi.save(t)
usagi.load()
usagi.read_json(path) -- read data/<path> as a Lua table
usagi.read_text(path) -- read data/<path> as a string
usagi.to_json(t) -- serialize a Lua table to a JSON string (same shape rules as usagi.save)
usagi.menu_item(label, callback) -- up to 3; callback `return true` keeps menu open
usagi.clear_menu_items()
usagi.toggle_fullscreen() -- flips fullscreen, returns the new state as bool
usagi.is_fullscreen()
usagi.quit() -- terminate the main loop (no-op visually on web)

-- Lifecycle callbacks

_config()
_init()
_update(dt)
_draw(dt)

-- Graphics

gfx.clear(color)
gfx.text(text, x, y, color)
gfx.text_ex(text, x, y, scale, rotation, color, alpha)
gfx.rect(x, y, w, h, color)
gfx.rect_fill(x, y, w, h, color)
gfx.rect_ex(x, y, w, h, thickness, color)
gfx.circ(x, y, r, color)
gfx.circ_fill(x, y, r, color)
gfx.circ_ex(x, y, r, thickness, color)
gfx.line(x1, y1, x2, y2, color)
gfx.line_ex(x1, y1, x2, y2, thickness, color)
gfx.tri(x1, y1, x2, y2, x3, y3, color)
gfx.tri_fill(x1, y1, x2, y2, x3, y3, color)
gfx.px(x, y, color)
gfx.get_px(x, y) -- read screen pixel: r, g, b, palette_index; expensive on web
gfx.spr(index, x, y)
gfx.spr_ex(index, x, y, flip_x, flip_y, rotation, tint, alpha)
gfx.get_spr_px(index, x, y) -- read sprite-sheet pixel: r, g, b, palette_index
gfx.sspr(sx, sy, sw, sh, dx, dy)
gfx.sspr_ex(sx, sy, sw, sh, dx, dy, dw, dh, flip_x, flip_y, rotation, tint, alpha)
gfx.shader_set(name)
gfx.shader_uniform(name, value)

-- Palette (PICO-8, 16 colors)

gfx.COLOR_BLACK, gfx.COLOR_DARK_BLUE, gfx.COLOR_DARK_PURPLE, gfx.COLOR_DARK_GREEN
gfx.COLOR_BROWN, gfx.COLOR_DARK_GRAY, gfx.COLOR_LIGHT_GRAY, gfx.COLOR_WHITE
gfx.COLOR_RED,   gfx.COLOR_ORANGE,    gfx.COLOR_YELLOW,     gfx.COLOR_GREEN
gfx.COLOR_BLUE,  gfx.COLOR_INDIGO,    gfx.COLOR_PINK,       gfx.COLOR_PEACH

-- Off-palette pure (255,255,255). Identity tint for spr_ex / sspr_ex.
gfx.COLOR_TRUE_WHITE

-- Sound

sfx.play(name)
sfx.play_ex(name, volume, pitch, pan)
music.play(name)
music.loop(name)
music.stop()
music.play_ex(name, volume, pitch, pan, loop)
music.mutate(volume, pitch, pan)

-- Input -- actions

input.pressed(action)
input.held(action)
input.released(action)
input.mapping_for(action)
input.last_source()

input.LEFT, input.RIGHT, input.UP, input.DOWN
input.BTN1, input.BTN2, input.BTN3
input.SOURCE_KEYBOARD, input.SOURCE_GAMEPAD

-- Input -- mouse

input.mouse()
input.mouse_held(button)
input.mouse_pressed(button)
input.mouse_released(button)
input.mouse_scroll()
input.set_mouse_visible(visible)
input.mouse_visible()

input.MOUSE_LEFT, input.MOUSE_RIGHT, input.MOUSE_MIDDLE

-- Input -- keyboard (bypasses the action keymap; prefer actions for game input)

input.key_held(key)
input.key_pressed(key)
input.key_released(key)

input.KEY_A   .. input.KEY_Z
input.KEY_0   .. input.KEY_9
input.KEY_F1  .. input.KEY_F12
input.KEY_SPACE, KEY_ENTER, KEY_ESCAPE, KEY_TAB, KEY_BACKSPACE, KEY_DELETE
input.KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN
input.KEY_LSHIFT, KEY_RSHIFT, KEY_LCTRL, KEY_RCTRL, KEY_LALT, KEY_RALT
input.KEY_BACKTICK, KEY_MINUS, KEY_EQUAL
input.KEY_LBRACKET, KEY_RBRACKET, KEY_BACKSLASH
input.KEY_SEMICOLON, KEY_APOSTROPHE, KEY_COMMA, KEY_PERIOD, KEY_SLASH

-- Effects (juice)

effect.hitstop(time)
effect.screen_shake(time, intensity)
effect.flash(time, color)
effect.slow_mo(time, scale)
effect.stop() -- stop all running effects

-- Util -- math

util.clamp(v, lo, hi)
util.sign(v)
util.round(v)
util.approach(current, target, max_delta)
util.lerp(a, b, t)
util.wrap(v, lo, hi)
util.flash(t, hz)
util.remap(v, start_a, end_a, start_b, end_b)

-- Util -- vectors

util.vec_normalize(v)
util.vec_dist(a, b)
util.vec_dist_sq(a, b)
util.vec_from_angle(angle, len)

-- Util -- geometry

util.point_in_rect(p, r)
util.point_in_circ(p, c)
util.rect_overlap(a, b)
util.circ_overlap(a, b)
util.circ_rect_overlap(c, r)

Compound assignment operators

Usagi runs each .lua source through a tiny preprocessor before handing it to the Lua VM, adding compound assignment sugar:

operator rewrite
+= x = x + y
-= x = x - y
*= x = x * y
/= x = x / y
%= x = x % y
State.score += 1
State.timer += dt

Limitations: the rewrite is line-anchored, so if cond then x += 1 end is left as-is (use longhand). The LHS is duplicated verbatim, so t[f()] += 1 calls f() twice.

The included .luarc.json from usagi init declares these as nonstandard symbols so the lua-language-server does not underline them as syntax errors.

Callbacks

Define any of these as globals for Usagi to call them:

_config

Supported fields:

function _config()
  return {
    name = "Snake",
    pixel_perfect = true,
    game_id = "com.example.snake",
    icon = 1,
    -- game_width = 480,   -- optional; default 320
    -- game_height = 270,  -- optional; default 180
    -- sprite_size = 32,   -- optional; default 16
    -- pause_menu = false, -- optional; default true
  }
end

icon (optional) is a 1-based tile index into your sprites.png, same indexing as gfx.spr. Omitted, the embedded Usagi bunny is used. The chosen tile is applied to the game window on Linux/Windows. At usagi export --target macos time the same tile is scaled up and packed into Resources/AppIcon.icns inside the .app, which is what the macOS Dock/Finder pick up.

_config() runs before the runtime is fully alive (the window doesn't exist yet), so its return value is read once at startup and cached. Editing _config() while the game is running won't update the title or any future config field on save; restart the session to pick up changes.

gfx

Draws to the screen. Positions are in game-space pixels (320×180). Colors are palette slot indices 1..16; use the named constants.

The _ex variants pack every power-arg into one fixed signature instead of trailing optionals. With a single _ex per primitive there's exactly one decision per draw ("simple or extended?"). If you want shorter call sites, write a thin wrapper.

Custom palettes (palette.png)

Put a palette.png at your project root to override the engine's default Pico-8 palette. Pixels are read in row-major order (left-to-right, top-to-bottom):

Behavior:

Recommended pattern: name your own slots when using a custom color palette. The built-in gfx.COLOR_* constants are named after Pico-8's slot ordering (slot 9 = COLOR_RED). With a custom palette, slot 9 might be a navy blue or a teal. The names don't match the colors anymore. Define your own constants once at the top of your project and use them everywhere:

-- e.g. for sweetie16
local COLOR = {
  NIGHT = 1, PURPLE = 2, RED = 3,    ORANGE = 4,
  YELLOW = 5, LIME = 6,  GREEN = 7,  TEAL = 8,
  NAVY = 9,  BLUE = 10,  SKY = 11,   CYAN = 12,
  WHITE = 13, SILVER = 14, GRAY = 15, SHADOW = 16,
}

gfx.clear(COLOR.NIGHT)
gfx.rect_fill(x, y, w, h, COLOR.RED)

Workflow tip: palette.png loads directly into Aseprite's palette panel with one click ("Edit → Preferences → Palette → Load"), so the same file drives both your engine colors and the swatches you paint with.

See examples/palette_swap for a runnable demo (ships sweetie16, uses a COLOR table for its named slots).

Custom fonts (font.png)

Put a font.png at your project root to override the bundled monogram font used by gfx.text / gfx.text_ex / usagi.measure_text. The PNG is a baked glyph atlas with metadata embedded as a zTXt chunk (see "Baking" below).

Scope of the override is intentionally narrow:

The font's natural line height drives usagi.measure_text and the per-glyph positioning, so a smaller custom font (e.g., Misaki Gothic 8×8) renders at 8 px and a larger one (Silver 5×9) renders at 21 px, both crisp at integer scales.

Baking a font:

usagi font bake <font.ttf> <size>

Examples:

# Drop into the current project (writes font.png in current working directory by default)
usagi font bake my_font.ttf 12

# Skip the kanji block for a font that covers it
usagi font bake misaki_gothic.ttf 8 --scripts all,-cjk

# Korean-only atlas (smaller output for a game that doesn't need other scripts)
usagi font bake silver.ttf 18 --scripts latin,latin-ext,punct,korean

# Write to a specific path
usagi font bake silver.ttf 18 --out my_proj/font.png

Behavior:

Behavior of the project drop-in:

Asian-language support: the bundled monogram font covers Latin / Cyrillic / partial Greek but no CJK. For Japanese, Chinese, or Korean text, grab a pixel font that covers the scripts you need and bake it:

# Silver: 5x9-ish with broad European + ~8k CJK ideographs + ~2k Hangul.
# Download from https://poppyworks.itch.io/silver (CC-BY-4.0).
usagi font bake Silver.ttf 18
# Drop the resulting font.png next to your project's main.lua.

See examples/custom_font for a working Silver-based demo that renders English, Cyrillic, Greek, Japanese, and Korean on the same screen.

Scaling sprites

There's no scale param on spr / spr_ex as those are fixed at the native sprite size. To draw a sprite scaled, use sspr_ex with a destination size that differs from the source size:

-- Draw sprite index 1 (16×16) at 2x scale at (x, y).
local sz = usagi.SPRITE_SIZE
gfx.sspr_ex(0, 0, sz, sz, x, y, sz * 2, sz * 2, false, false, 0, gfx.COLOR_TRUE_WHITE, 1.0)

If you find yourself reaching for variants often, wrap them. These three helpers cover most games:

-- Scaled draw of a source rect on the sheet. Doesn't go through `spr`
-- indexing — pick the source rect yourself with the TilePicker.
function sspr_scaled(sx, sy, sw, sh, dx, dy, scale)
  gfx.sspr_ex(
    sx, sy, sw, sh,
    dx, dy, sw * scale, sh * scale,
    false, false, 0, gfx.COLOR_TRUE_WHITE, 1.0
  )
end

-- Sprite by 1-based index with rotation around its center, native size.
function spr_rot(index, x, y, rotation)
  gfx.spr_ex(index, x, y, false, false, rotation, gfx.COLOR_TRUE_WHITE, 1.0)
end

-- Sprite by 1-based index with a tint applied, native size.
function spr_tinted(index, x, y, tint)
  gfx.spr_ex(index, x, y, false, false, 0, tint, 1.0)
end

The engine intentionally doesn't ship these as every game has slightly different conventions (whether scale should be integer-only, whether rotation centers somewhere other than the middle, whether tinted draws also need alpha), and forcing one shape on everyone hurts more than it helps. Copy and adapt.

input

Abstract input actions. Each action is a union over keyboard, gamepad buttons, and the left analog stick; any connected gamepad fires every action, so the Steam Deck's built-in pad and an external pad both work, and hot-swapping is transparent.

Action Keyboard Gamepad
LEFT arrow left / A dpad left / left stick left
RIGHT arrow right / D dpad right / left stick right
UP arrow up / W dpad up / left stick up
DOWN arrow down / S dpad down / left stick down
BTN1 Z / J south face (Xbox A, PS Cross), LB
BTN2 X / K east face (Xbox B, PS Circle), RB
BTN3 C / L north + west face (Xbox Y/X, PS Triangle/Square)

BTN1/BTN2/BTN3 are abstract action buttons. BTN3 binds both the north and west face buttons because either is easier to reach than crossing the diamond from BTN1's south position.

Nintendo Switch face-button swap. When a Switch pad is connected, BTN1 fires from the A button (east face) and BTN2 from the B button (south face), matching Nintendo's "A confirms, B cancels" convention. Triggers (L/R) and BTN3 are unchanged. The swap is automatic via GetGamepadName; from your game's perspective input.pressed(input.BTN1) still means "primary action."

input.pressed and input.released are edge-detected across keyboard, gamepad buttons, and analog sticks. Tilting the stick past the deadzone fires a single press the frame it crosses; releasing fires the frame it falls back inside.

Control glyphs (source-aware)

For UI prompts that adapt to the device the player is using:

local btn = input.mapping_for(input.BTN1) or "?"
gfx.text("Press " .. btn .. " to jump", 10, 10, gfx.COLOR_WHITE)

Mouse

Direct keyboard (if you really need it)

For dev hotkeys (toggling debug overlays, screenshotting, F-key shortcuts) and for keyboard-and-mouse-only games, you can read raw keyboard state by key:

if usagi.IS_DEV and input.key_pressed(input.KEY_F1) then
  State.show_debug = not State.show_debug
end

Use sparingly for gameplay. These bypass the action/keymap system on purpose, meaning they don't honor the player's pause-menu key remaps and they don't fire from a gamepad. Anything a player should be able to remap, or that a controller player needs to reach, belongs on input.held / input.pressed / input.released with an abstract action.

Available constants (all input.KEY_*): letters AZ, digits 09, function keys F1F12, SPACE, ENTER, ESCAPE, TAB, BACKSPACE, DELETE, arrows (LEFT, RIGHT, UP, DOWN), modifiers (LSHIFT, RSHIFT, LCTRL, RCTRL, LALT, RALT), and punctuation (BACKTICK, MINUS, EQUAL, LBRACKET, RBRACKET, BACKSLASH, SEMICOLON, APOSTROPHE, COMMA, PERIOD, SLASH). Numpad and the navigation cluster (Insert/Home/End/PgUp/PgDn) aren't exposed.

Raw gamepad reads (analog sticks, triggers, individual face buttons by index) are intentionally not exposed. The abstract input.held(input.BTN1) family covers gamepad input; if you need finer-grained control than that, you've likely outgrown Usagi. Fork the engine or use Love2D!

sfx

music

Background music streamed from disk (or the fused bundle). Only one track plays at a time; calling play, loop, or play_ex while another is playing stops the old one first.

All four play / loop / stop / play_ex calls are callable from _init, so a title track can start the moment the window opens (no one-frame gap waiting for _update).

Recognized extensions: .ogg, .mp3, .wav, .flac. OGG is recommended for music as they're small and cross-platform.

The file stem is the name; music/intro.ogg is music.play("intro"). Music lives in a separate directory from sfx because the formats and lifetimes differ — sfx is loaded fully into memory and one-shotted, music is decoded incrementally on the audio thread.

util

Drop-in math and geometry helpers. Pure Lua, no engine state, available as a global util table.

Functions taking shaped tables (vectors {x, y}, rects {x, y, w, h}, circles {x, y, r}) check their args and raise an error pointing at your call site when a field is missing, so a typo like util.rect_overlap({x=0, y=0, w=10}) fails with util.rect_overlap: arg 1 table missing or non-numeric field 'h' instead of a confusing nil-arithmetic explosion deep inside the helper.

Scalar math:

Vectors:

Geometry overlap:

usagi

Engine-level info.

Loading game data: JSON and text

Drop arbitrary game data (levels, dialog, tunable configs) under a data/ directory at your project root. usagi export bundles the whole tree, so the same paths resolve identically in dev and in exported builds.

Paths are forward-slash relative to data/. Nested subdirs are fine (data/levels/01.jsonusagi.read_json("levels/01.json")). Backslashes, absolute paths, and .. segments are rejected at the vfs boundary so a malicious or buggy path can't escape data/.

function _config()
  return { name = "Tile Demo" }
end

-- Read at the top of the chunk so live reload picks up edits to the JSON
-- without needing F5. The script re-runs whenever any data file mtime
-- changes, the same way it does for any .lua file.
local levels = usagi.read_json("levels.json")
local intro  = usagi.read_text("dialog/intro.txt")

function _draw(_dt)
  gfx.clear(gfx.COLOR_BLACK)
  gfx.text(intro, 4, 4, gfx.COLOR_WHITE)
  -- ... iterate `levels` ...
end

Hot reload: any save to a file under data/ triggers the same script re-run that a .lua save does. State globals (capitalized vars set in _init) are preserved across reloads; if you want a true reset, press F5. Bundled builds have no mtimes, so hot reload is a dev-only convenience; exported games read once from the bundle.

For CSV, use read_text + Lua splitting. A 3-line string.gmatch covers the simple-grid case (see examples/level_from_csv/).

Encoding a Lua table as JSON

usagi.to_json(t) returns a pretty-printed JSON string for any Lua table. It shares the validator with usagi.save, so the same shape rules apply: keys are all strings or a dense 1..n integer array; functions, userdata, NaN, and cycles raise an error with a clear message.

Useful when you want JSON without going through the save file: in-game devtools overlays, structured stdout logs, ad-hoc state inspection, or feeding data into another tool. Pair with usagi.read_json if you ever want to round-trip; reach for usagi.dump instead when you want a Lua table you could require or forgiving pretty-print that tolerates cycles and mixed-key tables.

local payload = { score = 200, run = { seed = 42, deaths = 1 } }
print(usagi.to_json(payload))
-- {
--   "score": 200,
--   "run": { "seed": 42, "deaths": 1 }
-- }

See examples/to_json.lua for a runnable demo.

Effects: hitstop, screen shake, flash, slow-mo

The effect.* module gives you four engine-level juice primitives. Each is a single call from anywhere in _init / _update / _draw; the engine decays them once per frame and threads them into the right point in the update / render loop, so you don't have to plumb shake offsets through your draws or gate _update on a freeze flag.

effect.hitstop(0.06)                     -- freeze _update for 60 ms
effect.screen_shake(0.3, 4)              -- shake 0.3 s, up to 4 game pixels
effect.flash(0.1, gfx.COLOR_WHITE)       -- white flash, fades over 100 ms
effect.slow_mo(1.5, 0.3)                 -- 1.5 s at 30% speed

Stacking. Across all four, longer duration wins; for the magnitude parameter, the latest call wins. effect.screen_shake(0.1, 2) followed by effect.screen_shake(0.5, 4) gives 0.5 s at intensity 4. Spam-calling is safe.

Pause. When the engine pause overlay is open, effect timers don't tick and shake is suppressed under the "PAUSED" view, so nothing decays or rattles while the game is held.

See examples/effect.lua for a runnable demo (one key per primitive plus a combo button).

Shaders (advanced)

Post-process GLSL fragment shaders run as the final pass when the game's render target is blitted to the window. Use them for CRT effects, palette swaps, vignettes, color grading, and so on.

Captures have a known limitation (see below).

API:

function _init() gfx.shader_set("crt") end

function _draw(_dt)
  gfx.shader_uniform("u_time", usagi.elapsed)
  gfx.shader_uniform("u_resolution", { usagi.GAME_W, usagi.GAME_H })
  -- ... your normal gfx.* calls ...
end

Cross-platform shader files. Desktop targets compile GLSL #version 330; the web target uses GLSL ES #version 100 (WebGL 1 / GLES 2). Include two files alongside each other to support both:

Web prefers _es.fs and falls back to .fs; desktop is the reverse. If only one is included, every platform that loads it runs that one. The fragTexCoord, fragColor, and texture0 inputs are provided by raylib on both targets. See examples/shader/ for a runnable CRT effect plus a Game Boy palette swap with both variants of each.

Live reload. Saving the active shader's .fs or .vs file rebuilds it in-place. Cached uniforms are replayed onto the new shader. Compile errors print to the terminal and keep the previous shader live.

Bundling. usagi export walks shaders/ and includes every .fs / .vs in the bundle, so shaders work the same in usagi dev, usagi run, .usagi files, and fused exes on every platform.

Captures don't include the shader. F8 / Cmd+F screenshots and F9 / Cmd+G GIF recording read the unshaded game render target, so post-process effects show up on screen but not in the saved file. Tradeoff: the shader runs at window resolution (CRT scanlines look smooth, not blocky) and captures stay at the game's 320x180 grid for clean shareable artifacts. If you need the shader baked into a capture, use your OS's screen recorder or screenshot tool against the game window.

Shaders resources:

Indexing

Sequence-style APIs (gfx.spr) are 1-based to match Lua conventions (ipairs, t[1], string.sub). gfx.spr(1, ...) draws the top-left sprite.

Palette constants are 1-based too: gfx.COLOR_BLACK is 1, gfx.COLOR_RED is 9. Pico-8's familiar 0..15 numbering is shifted up by one across the board so slot indices double as Lua array indices. Slot 0 and any index above the active palette's length render as a magenta sentinel.

Randomness

Lua's math.random is available as-is. Lua auto-seeds its PRNG at startup, so each run of usagi dev / usagi run (and each launch of an exported binary) produces a fresh sequence. No engine call is needed before calling math.random().

local n = math.random(1, 100)   -- integer in [1, 100]
local f = math.random()         -- float in [0, 1)

If you want a deterministic sequence (replays, tests, repeatable level generation) call stock Lua's math.randomseed(n) from _init. See examples/rng.lua for a small demo.

Coming from Pico-8?

Check out ./examples/pico8 to see how you can drop in a pico8.lua, require "pico8", and have a lot of the same functions as Pico-8.

The Pico-8 shim allows you to write code like in Pico-8:

-- check for input
if btn(0) then
  State.p.x = State.p.x - State.p.spd * dt
end

-- draw a sprite from sprites.png
spr(0, 20, 30)

Shortcuts

Writing Reload-Friendly Scripts

All Lua code chunks re-execute on save, so any top-level local bindings get re-bound on auto reload. A local State at module scope would get reset to a fresh table on every save and obliterate the running game; it has to be a global. The pattern:

General advice: if you want something to persist across the live reload, put it into State. If you're tuning something and want to see it change automatically, leave it inline.

The .luarc.json from usagi init enables the lowercase-global diagnostic to catch the most common footgun: forgetting local and accidentally creating a global named score, timer, etc. Capitalize anything you actually mean to make global; lowercase top-level assignments will warn.

See examples/hello_usagi.lua and examples/input.lua for some examples.

Reset

Usagi watches the running script file and re-executes it when you save. The new _update and _draw take effect on the next frame. Your current game state can be preserved across the reload so you can tweak logic mid-play without losing progress.

Press F5 (or Ctrl+R / Cmd+R) for a hard reset. The pause menu's Reset Game item does the same thing. Reset re-runs _init() so anything you build there starts from scratch, while leaving the rest of the session alone.

What a reset clears:

What a reset leaves alone:

You can also make use of usagi.IS_DEV to set up your State on a reset to keep you within a given scene or setup you want to refine quickly.

Examples

View the examples on Codeberg.

There are a variety of examples exercising the full Usagi API that you can browse and adapt. Their source is all public domain, so do with them what you want.

Bomberfrog: Alpha is a finished shoot-em-up made with Usagi that you can reference or use as a starting point for your own game. It includes scene switching, dev-only functionality, score tracking, and more.

SokoWorld is a Sokoban puzzle game made with Usagi with custom level parsing code, scene switching, and save data tracking.

Tools

Usagi tools window showing the TilePicker selected with monster sprites by Hexany Ives

usagi tools [path] opens a 1280×720 window with a tab bar for the available tools. The path is optional; pass a project directory (or a .lua file) to load its sprites.png and sfx/ assets. Without a path the tools open with empty state.

Switch tools via the tab buttons or with 1 (Jukebox), 2 (TilePicker), 3 (SaveInspector), 4 (ColorPalette).

Jukebox and TilePicker live-reload their assets: drop a new WAV in sfx/ or save a new sprites.png and the tools pick it up on the next frame.

Jukebox

Lists every .wav in <project>/sfx/ and lets you audition them. Selected sounds play automatically on selection change (Pico-8 SFX editor style), so you can just arrow through the list to hear each one.

TilePicker

Shows <project>/sprites.png with a 1-based grid overlay matching gfx.spr. Click a tile to copy its index, or right-drag to grab a rectangle for sspr. The current selection is shown in the header and highlighted on the sheet.

SaveInspector

Reads the project's _config().game_id and shows the current save.json contents alongside the resolved file path. Useful for debugging save formats and inspecting state between runs without leaving the editor.

ColorPalette

Shows swatches for each of the 16 default colors or your custom palette.png with the ability to click to copy the Lua value to your clipboard.

Bring Your Own Tools

Usagi doesn't include a sprite editor, sound effect generator, or music tracker. You can find assets to use on opengameart.org and itch.io or make your own. Here are some tools worth checking out that work well with Usagi:

Export

usagi export <path> packages a game for distribution. Default output is every platform plus a portable bundle:

$ usagi export examples/snake
$ tree export
export
├── snake-linux.zip          # Linux x86_64 fused exe
├── snake-linux-aarch64.zip  # Linux arm64 fused exe (Pi, ARM SBCs, ARM handhelds)
├── snake-macos.zip          # macOS arm64 fused exe
├── snake-windows.zip        # Windows x86_64 fused exe
├── snake-web.zip            # web export: index.html + usagi.{js,wasm} + game.usagi
└── snake.usagi              # portable bundle (usagi run snake.usagi)

Or pick one with --target:

$ usagi export examples/snake --target web
$ usagi export examples/snake --target windows
$ usagi export examples/snake --target bundle

Cross-Platform Templates

Non-host platforms come from "runtime templates" published alongside each Usagi release. The CLI fetches them on first use, caches them per-OS, and verifies each archive against its sha256 sidecar before extracting.

The host platform always works offline. Linux x86_64 running usagi export --target linux (or the linux slice of --target all) fuses against the running binary directly: no cache lookup, no network. First-time cross-platform export needs network; subsequent runs are offline.

Override the template source explicitly:

Building for Unsupported Platforms

Usagi publishes binaries for Linux x86_64, Linux aarch64, macOS aarch64, Windows x86_64, and web (wasm). If you're on a platform outside that set (macOS Intel, FreeBSD, etc.) the official downloads won't work, but you can build the engine from source and export games for yourself:

  1. Grab the source for the release you want. Either clone the repo and git checkout v<version> or download the source archive from the release page.

  2. Build with cargo build --release. See DEVELOPING.md for platform prerequisites (Windows in particular needs vcpkg + zlib).

  3. Use the resulting binary at target/release/usagi to develop and export:

    target/release/usagi export path/to/game

On a host outside the published set, usagi export (default --target all) also produces a host-fuse zip named <slug>-<os>-<arch>.zip alongside the four published-platform zips. Use --target host if you want only that zip without fetching the cross-platform templates.

The host zip only runs on the same OS/arch you built on; to ship to multiple unsupported platforms, build the engine on each one.

Web Shell

The web export ships a default HTML page that hosts the canvas. To use a custom page, drop a shell.html next to your main.lua and usagi export picks it up automatically. Override per-build with --web-shell PATH.

Notes

Porting to Love2D

When a project outgrows Usagi (you want iOS / Android, four action buttons, the full Love2D module surface), there's a one-shot port path to Love2D 11.5.

usagi loveify path/to/your-game path/to/your-game-love
cd path/to/your-game-love
love .

usagi loveify walks the source dir, rewrites compound-assignment operators (x += 1x = x + (1)) for LuaJIT compat, copies all assets verbatim, and drops in the Love shim runtime: usagi_shim.lua (~1800 lines of pure Lua that reimplements gfx.*, input.*, sfx.*, music.*, usagi.*, util.*, effect.*, and the custom font.png + palette.png paths against Love's APIs) plus a conf.lua (suppresses Love's default 800×600 window so your _config().game_width / game_height apply at boot). If your source has no custom font.png, the engine's bundled monogram font drops in too so gfx.text renders crisply out of the box. Refuses to overwrite an existing destination.

This is meant to be a one-time operation. After porting, your game has "graduated" from Usagi: you keep your gameplay code, but you own the shim file and the project layout, and future changes happen in your fork, not by re-running loveify. Edit the shim, gut it, replace it with idiomatic Love code, take it whatever direction your game needs.

Use cases the port unlocks:

What's intentionally not carried over. You reimplement these in your fork:

Web export with a loveified project is unverified. Love 11.5 doesn't ship a web target. You'd run the port through love.js or whatever the current best fork is. The shim has no web-specific code (it doesn't use threading or other modules that break under emscripten), so in theory it should work, but it hasn't been tested. Use usagi export --target web if web is the primary need.

The shim source is well-commented pure Lua at <your-project>/usagi_shim.lua. When you usagi loveify, it's now your code! Open it, read it, edit it. The original canonical copy lives in examples/loveify/ in the Usagi repo. Updates to the shim ship with new Usagi releases; once you've ported, you're on your own copy and that's the intended outcome. You can keep using the Usagi API if you want, extend it, or move away from it. It's totally up to you.

Debugging

With live reload, the fastest debugging loop is usually print. Drop a print into _update or _draw with the value you care about, save, and watch it tick in the terminal while the game keeps running.

For tables, stock print(my_table) shows something like table: 0x55a... which isn't useful. Use usagi.dump(t) to get a recursive pretty-print of any value:

print(usagi.dump(state))

Tables are recursed with sorted keys; arrays render in order; cycles show as <cycle>; functions / userdata / threads show as placeholders. The result is a string, so you can also draw it on screen during dev with gfx.text.

Other Lua tools worth knowing:

A small amount of defensive programming pays off well in Lua. The language is dynamic and silent: a typo turns a real value into nil, and you find out several frames downstream when something unrelated tries to index that nil. Asserting your assumptions, especially in _init and at function boundaries, collapses that distance: the failure points at the real bug instead of at the chain reaction it caused.

Verbose mode (USAGI_VERBOSE=1)

Set USAGI_VERBOSE=1 in your shell to turn on two extra streams:

Raylib's own boot chatter (GL info, audio device, gamepad detection) and its per-frame TEXTURE log are gated separately on USAGI_RAYLIB_VERBOSE=1. Kept distinct so the diagnostics stream doesn't get buried under raylib's per-frame output. Set both when you need everything:

USAGI_VERBOSE=1 USAGI_RAYLIB_VERBOSE=1 usagi dev path/to/game

Example output for a healthy 60 FPS game:

[usagi] -- startup snapshot --
[usagi] build release on linux
[usagi] gc inc pause=250 stepmul=200 stepsize=11200
[usagi] resolution 320x180 pixel-perfect=off sprite-size=16
[usagi] pause-menu=on palette=pico-8 font=bundled
[usagi] script=examples/hello_usagi.lua lua-heap-after-init=80 KB
[usagi] -- end startup snapshot --
[usagi] frame avg 16.67ms (p50 16.67 / p99 16.67 / max 16.67); over-budget 0/61; lua heap 164 KB

A regression of the "still runs but slower" shape would show:

[usagi] frame avg 43.21ms (p50 43.00 / p99 48.10 / max 51.20); over-budget 61/61; lua heap 12 KB

avg jumped 2-3×, over-budget is at the cap, and lua heap is pinned near zero (the GC is sweeping every allocation, so nothing survives the frame).

examples/diagnostics is purpose-built for this: short-lived table allocs in _update with controls to scale the rate, plus a burst button. Run it as USAGI_VERBOSE=1 just example diagnostics and watch the terminal.

Output is zero overhead when the env var is not set: the formatting macros short-circuit before touching the format args.

Set NO_COLOR=1 (any value, presence is what's checked) to suppress the ANSI color escapes on usagi's own log lines. Useful when piping output to a file or a CI log viewer that doesn't render ANSI cleanly. Usagi follows the no-color.org convention and also auto-disables color when stdout/stderr isn't a terminal, so most pipe / redirect cases are already covered without setting anything. PowerShell honors the same env var; set it for the current session with $env:NO_COLOR = "1", or persistently via [Environment]::SetEnvironmentVariable("NO_COLOR", "1", "User"). cmd uses set NO_COLOR=1.

Developing

Reference and Inspiration

Engine Assets

Download the Usagi Engine logo assets if you need them for any reason.

Credits

Usagi is built with Rust.

The full list of every transitive Rust crate Usagi depends on, with each license's text, lives at usagiengine.com/third-parties (also bundled in every release archive as THIRD_PARTY_LICENSES.md next to the binary). Regenerate with just licenses after touching dependencies; CI fails if it drifts.

(Un)license

Usagi's source code is dedicated to the public domain. You can see the full details in UNLICENSE.