Input, Time, and State #
A Budo app runs as a frame loop: once per frame, your callback reads the input, updates the app's state, and draws it. This page covers each part of that loop, from scheduling frames and timers to pointers, keys, text editing, and focus, and ends with ways to organize the state itself.
The frame loop #
Register your frame function with sys.animation.requestFrame. Budo calls it with a timestamp in milliseconds.
// Budo calls this function for every frame.
function frame(timestamp) {
// Advance the app's state.
update(timestamp);
// Draw the current state.
draw();
// Schedule the next frame.
sys.animation.requestFrame(frame);
}
// Start the loop once the entrypoint has loaded.
sys.animation.requestFrame(frame);Budo keeps a single frame callback: registering another one replaces it, and sys.animation.cancelFrame(handle) removes it. End every frame with requestFrame(frame), or with waitForInput(frame) when nothing moves (see below), so that each frame states how the next one should run. If the callback throws, the error is logged and the callback stays registered, so the same error repeats on every frame until you fix it.
Elapsed time #
For movement that runs at the same speed whatever the frame rate, multiply speeds by the time elapsed since the previous frame. You can compute it from the timestamp, or read deltaTime from the input snapshot, which is already in seconds.
function frame() {
// Seconds since the previous frame.
const dt = sys.input.get().deltaTime;
// Move 120 pixels per second, whatever the frame rate.
state.x += dt * 120;
// Draw, then schedule the next frame.
draw();
sys.animation.requestFrame(frame);
}Mind the units: the frame timestamp and sys.timer delays are in milliseconds, while input.deltaTime and input.totalTime are in seconds.
Drawing only when needed #
Interfaces and tools are still most of the time. For those frames, end with sys.animation.waitForInput(frame, timeoutMs) instead of requestFrame: the callback runs again on the next input event, when the canvas is resized, or after timeoutMs if you pass one. Until then the last frame stays on screen and Budo saves power: desktop builds sleep until an event arrives, Android stops rendering, and the web skips drawing. JavaScript timers still fire on time.
function frame(timestamp) {
const input = sys.input.get();
// A press starts a short flash animation.
if (input.pointer.pressed) flash = 1;
flash = Math.max(0, flash - 0.05);
draw();
// Animating: draw every frame. Idle: wait for the next input.
if (flash > 0) sys.animation.requestFrame(frame);
else sys.animation.waitForInput(frame);
}Pass a timeout for changes that happen on their own, such as a blinking caret or a message that should disappear: waitForInput(frame, 500). Apps that draw with sys.gl passes after the canvas (post effects) must keep calling requestFrame while those passes run, because only the canvas image is kept between frames. WebAssembly modules call animation_wait_for_input(timeoutMs) from frame to skip the following calls in the same way. With examples/budo-ui, call ui.nextFrame(frame), which waits for input whenever nothing moves.
Timers #
JavaScript apps also have sys.timer, for callbacks after a delay or at an interval. Lua apps schedule work from the frame loop instead.
// Run a callback once, after about one second.
sys.timer.once(1000, () => {
state.message = 'One second later';
});
// Toggle a field every quarter second; keep the ID to cancel it.
const pulse = sys.timer.every(250, () => {
state.blink = !state.blink;
});
// Cancel the repeating timer after 2.5 seconds.
sys.timer.once(2500, () => sys.timer.clear(pulse));once(delayMs, callback) and every(intervalMs, callback) return a timer ID for sys.timer.clear(id). Timers are checked between frames, so their precision is about one frame, and changes they make show on the next draw. A delay of 0 or less runs on the next frame. Use timers for interface delays, polling, and small choreographed events, and use frame time for physics and animation.
Pointers #
sys.input.get() returns a snapshot of the input for the current frame: the primary pointer, all active pointers, the mouse, keyboard modifiers, committed text, frame timing, and focus. Read it once near the start of the frame and pass it to the code that needs it.
// Read the input once, near the start of the frame.
const input = sys.input.get();
// The pointer went down this frame: remember where the drag starts.
if (input.pointer.pressed) {
state.anchorX = input.pointer.x;
state.anchorY = input.pointer.y;
}
// The pointer is held: follow it.
if (input.pointer.down) {
state.dragX = input.pointer.x;
state.dragY = input.pointer.y;
}input.pointer is the mouse on desktop and the primary touch on touch screens. It has x and y in canvas pixels, the movement since the previous frame in dx and dy, down while it is held, pressed on the frame it went down, and a type of 'mouse' or 'touch'. input.mouse adds the left, middle, and right buttons, leftPressed and rightPressed edges, and the scroll wheel in wheelX and wheelY.
For multi-touch gestures, read input.pointers. On touch devices it holds every active contact, each with its own id; on desktop it holds the mouse while the left button is down.
// Draw a circle under every active contact.
for (const pointer of input.pointers) {
sys.canvas.drawCircle(pointer.x, pointer.y, 18);
}Keys #
sys.input.isKeyDown(scancode) reports whether a key is held, and sys.input.isKeyPressed(scancode) whether it went down this frame. Both take SDL scancodes, which name a key by its physical position, whatever the keyboard layout. Use them for keys chosen for their position, such as WASD or the arrows, and for keys like Space, Return, and the function keys. Common scancodes are A 4, Return 40, Backspace 42, Tab 43, Space 44, Right 79, Left 80, Down 81, and Up 82. input.keyboard holds the shift, ctrl, alt, and meta modifiers.
// SDL scancodes for Space and the arrow keys.
const SDL_SCANCODE_SPACE = 44;
const SDL_SCANCODE_LEFT = 80;
const SDL_SCANCODE_RIGHT = 79;
// Toggle pause on the frame Space goes down.
if (sys.input.isKeyPressed(SDL_SCANCODE_SPACE)) {
state.paused = !state.paused;
}
// Move while an arrow key is held, at 240 pixels per second.
if (sys.input.isKeyDown(SDL_SCANCODE_LEFT)) {
state.x -= 240 * dt;
}
if (sys.input.isKeyDown(SDL_SCANCODE_RIGHT)) {
state.x += 240 * dt;
}isKeyPressed reports only the first frame of a press. For a key that repeats while held, such as Backspace in an editor, combine isKeyDown with your own timing.
Letter shortcuts #
For a shortcut named after a letter, such as M for a map, match input.text instead of a scancode. A scancode names a key's position on a QWERTY keyboard, so on an AZERTY keyboard the M scancode is the key to the right of N. input.text holds the characters the user typed, as the keyboard layout produced them.
// The M key, wherever the layout puts it (and not while a text field is focused).
const input = sys.input.get();
if (!input.textInputActive && input.text.toLowerCase().includes('m')) showMap = !showMap;Text input #
input.text contains the text committed since the previous frame. The platform has already applied the keyboard layout, Shift, dead keys, and character mapping, so append it as it comes. Keys that edit rather than type, such as Backspace, are read with scancodes.
const input = sys.input.get();
// Append what the user typed.
if (input.text) editorValue += input.text;
// Backspace (scancode 42) removes the last character.
if (sys.input.isKeyPressed(42)) editorValue = editorValue.slice(0, -1);Text editing sessions #
A real text field needs more: the soft keyboard on phones, input methods that compose characters (IMEs), autocorrection, and paste. Start a text editing session when the field gains focus. Budo then uses SDL text editing on desktop, a hidden browser editing element on the web, and Android's InputConnection with the soft keyboard. Each frame, apply the platform's edit, then send the field's value, its selection, and the caret's rectangle back with updateTextInput.
// The field gained focus: start a session with its current value.
sys.input.startTextInput({
text: editorValue,
selectionStart: caret,
selectionEnd: caret,
multiline: false,
});
function frame() {
const input = sys.input.get();
// Apply the platform's edit first: it replaces the whole value.
if (input.textEdit) {
editorValue = input.textEdit.text;
caret = input.textEdit.selectionEnd;
}
// While input.composition.active, draw input.composition.text at the
// caret (usually underlined): it is not part of the value yet.
// Then send the value, the selection, and the caret's rectangle back.
sys.input.updateTextInput({
text: editorValue,
selectionStart: caret,
selectionEnd: caret,
caret: { x: caretX, y: caretY, width: 1, height: lineHeight },
});
sys.animation.requestFrame(frame);
}Call sys.input.stopTextInput() when the field loses focus.
input.textEdit is a complete replacement of the value and selection. It covers typing, deletion, selection replacement, paste, autocorrection, and soft keyboard edits, so always apply it before calling updateTextInput: sending the old value back first would overwrite the platform's edit. input.composition holds { active, changed, text, selectionStart, selectionEnd } for the text an input method is composing; it persists across frames, and only changed is per frame. Selection offsets count UTF-16 code units, and the caret rectangle is in the same physical pixels as canvas drawing, so the platform can place its candidate window next to it.
On the web and on Android, the platform edits the field itself: paste, selection, and editing shortcuts already arrive as textEdit, and input.nativeTextEditing is true. On desktop it is false, so an editor implements its own shortcuts, using sys.device.setClipboardText and sys.device.getClipboardText for copy, cut, and paste (see Clipboard).
On Android, starting a session shows the soft keyboard. Back and Done (or Return in a single-line field) finish any composition and deliver the final textEdit before the keyboard closes.
examples/budo-ui (lib/widgets/text.js) has a complete text field: focus, a blinking caret, navigation, selection, clipboard shortcuts, full replacement edits, underlined composition text, and candidate window placement.
Focus #
The input snapshot's focused is false while the app's window does not have focus. Use it to pause what should not continue in the background.
function frame() {
const input = sys.input.get();
// Without focus: show a paused state instead of advancing the game.
if (!input.focused) {
drawPausedOverlay();
// Keep the loop running so the app resumes when focus returns.
sys.animation.requestFrame(frame);
return;
}
// With focus: update and draw as usual.
update(input);
draw();
sys.animation.requestFrame(frame);
}In a game, this guard keeps the simulation still while the player is in another window. A creative tool might keep drawing but ignore pointer drags until focus returns.
Organizing state #
Budo does not prescribe a way to manage state. A plain object is a good start.
// All of the app's state in one plain object.
const state = {
// The current interaction mode.
mode: 'idle',
// Position of the active object, in pixels.
x: 120,
y: 120,
// Velocity, in pixels per second.
velocityX: 0,
velocityY: 0,
// -1 when nothing is selected.
selectedId: -1
};As the app grows, split it by responsibility. A common shape is state.js, input.js, draw.js, and a short main.js that shows the frame's flow at a glance.
// The shared state.
import { state } from './state.js';
// Turns input into state changes.
import { updateFromInput } from './input.js';
// Draws the state.
import { drawScene } from './draw.js';
function frame(timestamp) {
// Read the input once and pass it down.
const input = sys.input.get();
// Update the state from input and time.
updateFromInput(state, input, timestamp);
// Draw the updated state.
drawScene(state);
// Schedule the next frame.
sys.animation.requestFrame(frame);
}Shader programs, paths, fonts, SVG images, audio buffers, databases, and neural models are runtime resources identified by handles. Keep a handle in your state for as long as you use the resource, and release it when you are done.
Long work #
Everything in a frame runs before the next frame can start, so a long computation freezes input, network callbacks, audio streams, and drawing. Split expensive work you control into chunks and advance it a little each frame, showing progress as it goes. File and network APIs that take callbacks or return promises already keep the loop running. For model inference, decide whether a synchronous run is short enough, and if not, show the user that the app is working.