gbalua
v0.4.1
Published
Write Game Boy Advance games in a PICO-8-flavored Lua that compiles to native ARM.
Downloads
722
Maintainers
Readme
GBA Lua SDK
Make games for the Game Boy Advance by writing a PICO-8-flavored Lua instead of C or ARM assembly.
The SDK ahead-of-time compiles your Lua to C, builds it with a bundled ARM
toolchain against a libtonc + maxmod runtime, and produces a .gba ROM that runs
in mGBA and on real hardware. No interpreter, no VM: your Lua
becomes native ARM machine code.
If you know PICO-8 you'll feel at home (spr/btn/_init/_update/_draw,
Lua syntax) - but this SDK leans into what the GBA can do that a fantasy console
can't: 128 hardware sprites, rotate/scale (affine) sprites, four scrolling tile
layers, Mode 7, hardware windows, alpha blending and fades, mosaic, per-scanline
raster effects, battery saves, and streamed module music. Familiarity, not
compatibility.
This is a fork of the GameTank Lua SDK; it reuses that compiler front-end and PICO-8 number model, and retargets the back end to native ARM.
Your first game
The whole hello, one main.lua with no asset files: a greeting plus a hardware
sprite you move with the d-pad. _update60 runs the movement 60 times a second;
_draw redraws the sprite every frame (examples/hello/main.lua):
-- The screen is 240x160. spr uses the built-in default sheet, so no art file
-- is needed. Colors are PICO-8-style indices 0-15 (1 dark-blue, 14 pink).
local x, y = 112, 72
function _update60() -- 60fps input + movement
if (btn(1)) then x += 2 end -- right
if (btn(0)) then x -= 2 end -- left
if (btn(3)) then y += 2 end -- down
if (btn(2)) then y -= 2 end -- up
end
function _draw()
cls(1) -- dark-blue background layer
print("hello gba", 88, 24, 14) -- greeting near the top, pink
spr(0, x, y, 2, 2) -- the hardware sprite, redrawn every frame
endBuild it and play it in a window - no external emulator needed:
node bin/gbalua.js run examples/hello/main.luagbalua run opens a window over the bundled mGBA core (arrows = d-pad, Z/X =
B/A, Enter = START), via the shared romdev-core-runner
SDL host. Or build a .gba ROM to ship:
node bin/gbalua.js build examples/hello/main.lua -o hello.gbaRun hello.gba in mGBA (or any GBA emulator) or flash it to
a cartridge. That's the whole loop: write main.lua, run it, ship the .gba.
Colors are PICO-8-style indices 0-15 (0 black, 1 dark-blue, 10 yellow,
14 pink); pal() / spr_col() reach the full 15-bit BGR555 palette (32768
colors) at runtime when you want more.
Featured example: a full shmup
examples/starfall is a complete little shmup - hardware
sprites, a scrolling starfield, music + sfx, a HUD, win/lose states - in one
main.lua. That's what this SDK is for:
node bin/gbalua.js build examples/starfall/main.lua \
--sheet examples/starfall/shmup_sheet.png \
--map examples/starfall/space_bg.png -o starfall.gbaRequirements
- Node.js 24+
- nothing else —
npm installbrings the whole toolchain as dependencies: arm-gcc + libtonc + maxmod, all as WebAssembly (viaromdev-platform-gba+romdev-maxmod). No devkitPro, no native tools to build or install.
The build runs the WASM toolchain in-process (cc1-arm → as → ld →
objcopy) — no server, no daemons. It can also talk to a running romdev
server instead (GBALUA_BACKEND=mcp, faster for rapid rebuilds since the
server keeps warm toolchain workers). See compiler/build-gba.mjs.
gbalua run opens the window through
romdev-core-runner (the
one SDL host shared across the whole SDK family and the romdev playtest tool).
The window needs @kmamal/sdl, an optional dependency of the runner (pulled
by npm install, prebuilt for the common platforms); on a headless box or
unsupported platform, run prints a clear message and you build a .gba for
an external emulator instead. build never needs it.
The screen and the two rendering modes
The GBA screen is 240×160. Two ways to draw, and they are one either-or (a hardware mode bit); sprites, sound, and all the color effects compose over both.
- Tile mode (Mode 0) - the real game path. Four hardware tile layers that
scroll for free, plus 128 hardware sprites on top. This is how scrolling GBA
games work; it's what the flagship example (
starfall) uses. - Bitmap mode (Mode 4) - an immediate-mode 8bpp framebuffer for
pset/rect/circ/line+ text. Simplest to start with (the hello above), single-buffered, slower per-pixel. Sprites still compose on top.
You don't pick a mode explicitly - using tile-layer verbs (map_show,
tileset, layer_*, mode7) puts you in tile mode; the immediate draw verbs
use bitmap mode.
The PICO-8 contract
Define _update60() (60 fps) or _update() (30 fps), plus _draw(), and
optionally _init(). The runtime latches input before each update and ends the
frame after _draw() (OAM flush, vblank).
Numbers are PICO-8 numbers: 16.16 fixed point. sin/cos/atan2 use turns
(0..1) with PICO-8's screen-space-inverted sin. The compiler infers which values
stay integral and keeps them in fast 32-bit ints - an optimization, never a
semantic change.
The dialect keeps PICO-8's syntax: +=-style compound assignment, one-line
if (cond) stmt / while (cond) stmt, !=, \ floor division, // comments,
hex/binary literals with fractions, and multiple assignment (x, y = 64, 32).
Arrays are 1-indexed (Lua/PICO-8 style): a = array(8), then a[1] is the
first element (a[0] is out of bounds).
API at a glance
| | |
|---|---|
| lifecycle | _init _update _update60 _draw |
| bitmap draw | cls camera color pset pget sset clip rect rectfill circ circfill line |
| 16-bit bitmap | mode15() true color · rgb15(r,g,b) · cls15 pset15 flip15 - a 160×128 BGR555 framebuffer |
| sprites | spr(n,x,y,[w,h],[fx,fy]) hardware OBJ · spr8(t,x,y,[flip]) · spr_pal spr_prio |
| affine sprites | sprr(n,x,y,angle,scale) rotate+scale · sprr2(n,x,y,angle,sx,sy) non-uniform |
| tile layers | map_show tileset tilemap layer_show layer_pri layer_scroll parallax camera mget/tget/tset |
| mode 7 | mode7() · mode7_cam(x,y,angle,[zoom]) · mode7_off() - an affine plane on BG2 |
| affine BG | abg_setup(tiles,ntiles,map,msize,[pal]) · abg_cam(x,y,angle,[zoom]) · abg_off() - your own rotate/scale layer |
| windows | window(x0,y0,x1,y1) spotlight · window_inside/window_outside/window_obj · window_off |
| color effects | fade(amount,[white]) · blend(layer,alpha) · blend_off · mosaic/mosaic2 · backdrop · screen_off/screen_on |
| palette | pal(i,r,g,b) BG · spr_col(i,r,g,b) OBJ · hgradient(table) per-scanline backdrop |
| animation | anim(slot,first,last,fps) loop · anim_once · anim_pingpong · anim_reset · anim_done |
| input | btn(i,[pl]) btnp(i,[pl]) - 0-3 d-pad, 4=A, 5=B, 6=L, 7=R, 8=START, 9=SELECT |
| math | flr ceil abs sgn sqrt min max mid sin cos atan2 rnd srand t/time + bit ops |
| data | array(n,[v]) 16.16 · array8(n,[v]) bytes 0-255 · pool(n) · dma(dst,src,n)/dma_fill fast moves |
| save | save(slot,array8,n) · load(slot,array8,n) - battery SRAM, 16 slots × 1 KB |
| timer | timer_start() · timer_read() - free-running Timer 3, sub-frame profiling |
| sound | music(n,[loop]) module music · sfx(n,[ch]) · sfx_ex(n,vol,pan,pitch) · sfx_volume |
See docs/CHEATSHEET.md for every verb with signatures.
Assets
--sheet sprites.png imports a sprite sheet, --map level.png a tilemap, and
--mode7 plane.png an affine plane, via a self-contained PNG → tile converter
(compiler/png-tiles.mjs). All three also accept the formats artists actually
work in — Aseprite (.ase/.aseprite, frame 0 flattened) and Tiled
(.tmx, visible layers composited; embed the tileset in the map) — imported,
never re-invented. They're CLI flags on build:
node bin/gbalua.js build --target gba mygame/main.lua \
--sheet mygame/sprites.ase --map mygame/level.tmx -o mygame/game.gbaThe examples/ directory shows each subsystem in use:
| example | shows |
|---|---|
| showcase | a scene-cycling tour of the whole feature set (L/R to switch scenes) |
| starfall | a complete shmup - tile mode, sprite HUD, module music + SFX |
| effects | blend / fade in bitmap mode |
| mode7 | an affine plane you rotate, zoom, and drive over |
| windows | a hardware spotlight over the Mode 7 plane |
| anim | frame-range animation helpers |
| hwtest | hgradient raster gradient, SRAM save/load, the timer |
Sound
Music is a streamed module (maxmod), and SFX are sampled one-shots - trigger both by index, PICO-8 style:
function _init()
music(0) -- start module 0, looping
end
function _update60()
if btnp(4) then sfx(0) end -- A -> a sound effect
if btnp(5) then sfx_ex(1, 512, 200, 1.5) end -- B -> louder, panned, +pitch
endmusic(-1) stops; music(n, false) plays once. sfx_ex(n, vol, pan, pitch)
gives per-shot volume (0-1024), pan (0-255, 128 center), and pitch (a 16.16
multiplier); sfx_volume(v) sets the master SFX level.
Bring your own music with --music song.xm on build (repeatable —
music(0) plays the first module, music(1) the second, …; .xm/.mod/
.it/.s3m all work). The soundbank is compiled at build time by
romdev-maxmod, a faithful pure-JS port of devkitPro's mmutil. Or link a
prebuilt bank with --soundbank bank.bin. With neither flag, the default
soundbank (assets/soundbank.bin) ships a chiptune as module 0.
Not-Lua walls (loud, never silent)
Conditions must be boolean (if x ~= 0 then, not if x then - Lua calls 0
truthy, C doesn't, and the compiler refuses to guess). No nil, closures,
metatables, coroutines, string concatenation, or goto. Every unsupported
feature is a compile-time error that says what to write instead.
Repo layout
compiler/ Lua→C compiler + the GBA build driver (build-gba.mjs) and PNG
importer (png-tiles.mjs) · gba-sdk/ the C runtime (thin libtonc/maxmod
wrappers: gba_api.c frame+sprites+input, gba_bg.c tile layers, gba_mode7.c,
gba_win.c, gba_fx.c effects, gba_sound.c, gba_hw.c save+timer,
gba_text.c, gba_anim.c, gba_math.c) · assets/ the default soundbank +
its generators · bin/gbalua.js CLI · examples/.
Docs
| doc | what | |---|---| | docs/CHEATSHEET.md | the full GBA Lua API reference | | docs/CHEATSHEET_FOR_PICO8_USERS.md | per-function PICO-8 → GBA map |
License
MIT. gba-sdk/ wraps libtonc and
maxmod (both zlib/BSD-style); the compiler
front-end derives from the GameTank Lua SDK (MIT).
