Lua and WebAssembly #

Budo apps can also be written in Lua or WebAssembly. A Lua app calls the same sys API as JavaScript, with Lua tables in place of objects and arrays. A WebAssembly module imports host functions from env and passes strings and data through its linear memory.

This page covers what changes when you leave JavaScript, and what stays JavaScript only.

Lua lifecycle #

Create main.lua in the project directory. Budo runs it once; to animate, register a frame callback with sys.animation.requestFrame, and request the next frame from inside it.

main.lua
-- Store the circle position between frames.
local x = 80

-- Budo calls this function when a frame is scheduled.
local function frame(timestamp)
  -- Clear the previous frame.
  sys.canvas.clear('#101116')
  -- Choose the circle fill color.
  sys.canvas.setFillColor('#7c6ff7')
  -- Draw the circle at the current x position.
  sys.canvas.drawCircle(x, 100, 32)
  -- Move the circle a little for the next frame.
  x = x + 1
  -- Request another frame.
  sys.animation.requestFrame(frame)
end

-- Start the animation loop.
sys.animation.requestFrame(frame)

sys.animation.start(callback) is an alias for requestFrame.

Lua apps get the standard Lua libraries plus four globals: sys, console, fetch, and json_parse.

Lua data shapes #

Every list Budo returns to Lua is a 1-based table, and every list you pass in is one too.

main.lua
-- Read the available MIDI input devices.
local devices = sys.midi.getInputDevices()
-- Lua arrays returned by Budo are 1-based.
for i = 1, #devices do
  -- Log the device name for a picker or diagnostics.
  console.log(devices[i].name)
end

Binary data is a Lua string, where JavaScript would use an ArrayBuffer. sys.files and sys.assets work as in JavaScript (see Data, Files, and Network): sys.files paths start with assets/ or files/, and sys.assets paths omit the assets/ prefix.

main.lua
-- Read a bundled binary file as a Lua string.
local data = sys.files.readBinary('assets/image.bin')
-- The same file through the read-only assets shortcut.
local same_data = sys.assets.readBinary('image.bin')
-- The length operator counts bytes.
console.log('Bytes: ' .. #data)

Writes go through sys.files.writeText and sys.files.writeBinary under files/, and return the saved path, or nil on failure.

SQLite queries return a list of row tables.

main.lua
-- Open or create the SQLite database.
local db = sys.db.open('notes')
-- Query rows as an array of Lua tables.
local rows = sys.db.query(db, 'SELECT id, title FROM notes')
-- Iterate through the returned rows.
for i = 1, #rows do
  -- Read a column by name.
  console.log(rows[i].title)
end

Neural tensors are tables with data and an optional shape. sys.neural.run returns one table per output, with data, shape, and dtype.

main.lua
-- Load an ONNX model (app.json must contain "neural": true).
local model = sys.neural.loadModel('model.onnx')
-- Run inference with a named input tensor.
local result = sys.neural.run(model, {
  -- The table key must match the model input name.
  input = {
    -- Tensor data is supplied as a Lua array.
    data = {0.0, 1.0, 0.5, 0.25},
    -- Shape follows the tensor dimensions expected by the model.
    shape = {1, 4}
  }
})

Lua fetch #

Lua has no promises, so fetch takes a callback, which receives the response table or an error message. Pass an options table before the callback to set method, headers, or body.

main.lua
-- Fetch data from an allowed domain.
fetch('https://api.example.com/data', function(response, error)
  -- Handle policy or transport errors first.
  if error then
    -- Log the error text supplied by the runtime.
    console.log(error)
    return
  end

  -- Parse the JSON response body into a Lua table.
  local data = json_parse(response.body)
  -- Log the HTTP status code.
  console.log('Status: ' .. response.status)
end)

The response table includes status, statusText, ok, url, redirected, headers, body, and bodyLen.

Lua differences from JavaScript #

Method names match JavaScript, so most examples translate line by line: objects become tables, promises become callbacks, and typed arrays become tables or strings.

Lua has no sys.timer. To delay an action, compare the frame timestamp against the time you want it to happen.

Budo adds no module system to Lua, so keep a Lua app in main.lua.

What stays JavaScript only #

Some parts of Budo are available in JavaScript and TypeScript only, not in Lua or WebAssembly: timers (sys.timer), drawing into textures (sys.graphics), ES modules, the budo-ui toolkit, audio streams and decoders, audio analysis, block reads of large files (sys.files.openRead), sys.files.appendBinary, and the system file pickers (pickText, saveText). Choose JavaScript when your app depends on one of them.

WebAssembly lifecycle #

WebAssembly apps run on desktop, through Wasmtime; they cannot be exported to web or packaged for Android. Write the module as text in main.wat, which suits small examples, or compile it from another language into main.wasm. Budo calls two optional exports: init, once after the module loads, and frame(timestamp), on every frame.

main.wat
(module
  ;; Import a host function that clears the canvas with an ARGB color.
  (import "env" "canvas_clear" (func $canvas_clear (param i32)))
  ;; Import a host function that draws a circle.
  (import "env" "canvas_draw_circle" (func $canvas_draw_circle (param f32 f32 f32)))
  ;; Import a host function that sets the fill color.
  (import "env" "canvas_set_fill_color" (func $canvas_set_fill_color (param i32)))

  ;; Export a frame function so Budo can call it each frame.
  (func (export "frame") (param $timestamp f32)
    ;; Clear the frame with an opaque dark color.
    i32.const 0xff101116
    call $canvas_clear

    ;; Select an opaque purple fill color.
    i32.const 0xff7c6ff7
    call $canvas_set_fill_color

    ;; Set the circle center x coordinate.
    f32.const 120
    ;; Set the circle center y coordinate.
    f32.const 100
    ;; Set the circle radius.
    f32.const 32
    ;; Draw the circle with the current paint.
    call $canvas_draw_circle
  )
)

frame receives the timestamp in milliseconds. A module without frame that never draws runs init without opening a window, then ends. To end the app yourself, import app_exit(i32) and call it with an exit status.

WebAssembly memory and strings #

Imports that take only numbers need nothing more. Imports that take strings or buffers read them from the module's memory, so the module must export it as memory. A string is passed as a pointer and a byte length.

main.wat
(module
  ;; Export memory so the host can read string bytes.
  (memory (export "memory") 1)
  ;; Store a UTF-8 string at byte offset 16.
  (data (i32.const 16) "Hello from WASM")
  ;; Import a text drawing function that reads pointer and length pairs.
  (import "env" "canvas_draw_text" (func $canvas_draw_text (param i32 i32 f32 f32 f32) (result f32)))

  ;; Export the frame function called by Budo.
  (func (export "frame") (param f32)
    ;; Pass the string pointer.
    i32.const 16
    ;; Pass the string length.
    i32.const 15
    ;; Pass the x position.
    f32.const 40
    ;; Pass the y position.
    f32.const 80
    ;; Pass the font size.
    f32.const 24
    ;; Draw the text and receive its measured width.
    call $canvas_draw_text
    ;; Drop the measured width because this example does not need it.
    drop
  )
)

File, network, shader, SVG, SQLite, MIDI, and neural imports pass strings the same way.

Imports that take lists use the same memory, in three shapes:

  • Arrays of numbers: canvas_set_linear_gradient(x0, y0, x1, y1, colorsPtr, stopsPtr, count) reads count u32 ARGB colors at colorsPtr and, unless stopsPtr is -1, count f32 positions at stopsPtr.
  • Records: canvas_draw_rich_text(spansPtr, count, x, y, width, align, lineHeight, maxLines) reads count 16-byte spans { i32 textPtr, i32 textLen, f32 size, u32 argb }; a size of 0 uses the default and a color of 0 the paint color.
  • JSON: accessibility_update(jsonPtr, jsonLen) takes the same node objects as sys.accessibility.update, and accessibility_take_actions(ptr, max) writes a JSON array of actions and returns its length (it writes nothing when the buffer is too small, so call it again with more room).

Results with several values come back through an output pointer: canvas_measure_paragraph(..., outPtr) writes { f32 width, f32 height, i32 lines }. Device preferences use one import per value, device_get_preference(id), and frames on demand use animation_wait_for_input(timeoutMs) from frame. The API reference lists every import.

WebAssembly capabilities #

A module that imports a function the host does not provide fails to load. The neural imports exist only in Budo builds with ONNX support, so a module that uses them loads only there.

To check a feature at run time, import its probe: capability_neural, capability_llamacpp, capability_midi, capability_udp, capability_http, and capability_sensors each return 1 when the feature is available and 0 otherwise. capability_llamacpp always returns 0 in WebAssembly.

The WebAssembly API is narrower than JavaScript's. It cannot load fonts, write files, or play audio buffers; SQLite queries return JSON strings; HTTP responses and UDP datagrams are polled rather than delivered to callbacks; and neural tensors are float32 only.

Choosing a runtime #

JavaScript and TypeScript have the complete API, run on every platform, and are the fastest way to iterate. Lua suits compact scripts and game logic, runs on desktop, web, and Android, and covers most of the API. WebAssembly fits when you already have a compiled core in another language; it runs on desktop only.

Drawing, input, and the frame loop work the same way in all three, so choose the language that fits your app and your team.