Input, Time, and State #
Animation callbacks #
JavaScript and Lua apps render by registering a callback with sys.animation.requestFrame. Budo stores one active animation callback. To keep animating, request the next frame before the callback returns.
// Budo calls this function when it is time to render a frame.
function frame(timestamp) {
// Advance app state before drawing it.
update(timestamp);
// Draw the current state to the canvas.
draw();
// Request another frame so animation continues.
sys.animation.requestFrame(frame);
}
// Start the frame loop once the entrypoint has loaded.
sys.animation.requestFrame(frame);The timestamp is in milliseconds. It is normal to convert it to seconds for simulation code.
// Store the previous timestamp so each frame can compute elapsed time.
let previous = 0;
// Convert Budo's millisecond timestamp into a seconds-based delta.
function frame(timestamp) {
// The first frame has no previous timestamp, so use a zero delta.
const dt = previous === 0 ? 0 : (timestamp - previous) / 1000;
// Save this timestamp for the next frame.
previous = timestamp;
// Update simulation state with frame-rate-independent time.
update(dt);
// Render the updated state.
draw();
// Keep the loop alive.
sys.animation.requestFrame(frame);
}sys.animation.cancelFrame(handle) clears the stored callback. The current handle value is 1, so treat it as a cancellation token rather than as a persistent per-callback identity.
Timers in JavaScript #
JavaScript apps also get sys.timer. Timers are checked once per frame, so they are frame-resolution timers rather than real-time threads.
// Run this callback once after roughly one second of frame time.
sys.timer.once(1000, () => {
// Timers are useful for UI events and lightweight scheduling.
console.log('One second later');
});
// Toggle a state field every quarter second.
const pulse = sys.timer.every(250, () => {
// Timer callbacks run between frames, so state changes are visible next draw.
state.blink = !state.blink;
});
// Cancel the repeating timer when the app no longer needs it.
sys.timer.clear(pulse);Use timers for UI delays, repeated polling inside the app, and small choreographed events. Use the frame timestamp for physics and animation.
Input snapshots #
sys.input.get() returns the current input state. It includes the primary pointer, all active pointers, mouse button edges, keyboard modifiers, committed text, frame timing, and focus.
// Read input once near the start of the frame.
const input = sys.input.get();
// Capture a drag origin when the primary pointer is newly pressed.
if (input.pointer.pressed) {
// Store the pointer's x coordinate in app state.
state.anchorX = input.pointer.x;
// Store the pointer's y coordinate in app state.
state.anchorY = input.pointer.y;
}
// Continue updating drag position while the primary pointer is held.
if (input.pointer.down) {
// Track the latest pointer x coordinate.
state.dragX = input.pointer.x;
// Track the latest pointer y coordinate.
state.dragY = input.pointer.y;
}The primary pointer is the mouse on desktop and the primary active contact on touch devices. For multi-touch gestures, read input.pointers.
// Draw feedback for every active touch contact.
for (const pointer of input.pointers) {
// Each pointer contains x and y coordinates in canvas space.
sys.canvas.drawCircle(pointer.x, pointer.y, 18);
}On desktop, pointers contains the mouse while the left button is down. On touch devices, it contains every active touch contact.
Keyboard input #
Use sys.input.get().text for simple user-entered text. It contains layout-aware text committed since the previous frame, so AZERTY, QWERTY, Shift, dead keys, and platform character mapping are handled by the host.
Editors should start a text session. A session activates SDL text editing on desktop, a hidden browser textarea on web, and Android's InputConnection plus soft keyboard. Synchronize the complete value, UTF-16 selection, and physical-pixel caret rectangle each frame:
sys.input.startTextInput({
text: editorValue,
selectionStart: caret,
selectionEnd: caret,
multiline: false,
});
function frame() {
const input = sys.input.get();
// Apply the platform's authoritative edit before synchronizing this frame.
if (input.textEdit) {
editorValue = input.textEdit.text;
caret = input.textEdit.selectionEnd;
}
// input.composition persists while preedit is active. Draw its text at the
// caret, commonly underlined, and use selectionEnd for the preedit caret.
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 focus leaves. textEdit is a full authoritative replacement, which covers commit, selection replacement, deletion, paste, autocorrection, and mobile keyboard edits. Always apply it before calling updateTextInput; sending the previous model value back first can overwrite a platform edit. composition contains {active, changed, text, selectionStart, selectionEnd} and persists across frames; only changed is per-frame. Selection offsets are UTF-16 code units. Candidate-window caret coordinates use the same physical pixels as canvas drawing.
On Android, starting a session focuses the native InputConnection and shows the soft keyboard. Back and single-line Done/Return finish any composing span and publish the final full textEdit before the keyboard closes. The Android bridge rejects queued pre-commit frame snapshots until the app echoes the new model value, so finishing composition cannot revert the field.
See examples/gui-framework for a retained input control that implements focus, blinking caret, navigation, full replacement edits, underlined IME preedit text, and candidate positioning.
Keyboard queries use SDL scancode values and remain layout-independent. isKeyDown reports a held physical key. isKeyPressed reports a per-frame physical-key edge. Use them for movement, arrows, function keys, and shortcuts rather than translating letter scancodes into text.
const input = sys.input.get();
if (input.text) editorValue += input.text;
if (sys.input.isKeyPressed(42)) editorValue = editorValue.slice(0, -1); // Backspace// SDL scancode for the Space key.
const SDL_SCANCODE_SPACE = 44;
// SDL scancode for the Left Arrow key.
const SDL_SCANCODE_LEFT = 80;
// SDL scancode for the Right Arrow key.
const SDL_SCANCODE_RIGHT = 79;
// Toggle pause only on the frame where Space is pressed.
if (sys.input.isKeyPressed(SDL_SCANCODE_SPACE)) {
// Store pause state in your app model.
state.paused = !state.paused;
}
// Move left while the Left Arrow key remains held.
if (sys.input.isKeyDown(SDL_SCANCODE_LEFT)) {
// Multiply by dt so movement speed is frame-rate independent.
state.x -= 240 * dt;
}
// Move right while the Right Arrow key remains held.
if (sys.input.isKeyDown(SDL_SCANCODE_RIGHT)) {
// Use the same speed as the left movement path.
state.x += 240 * dt;
}For text-entry-heavy applications, build a small input component that owns key repeat, selection, and editing behavior. Budo gives low-level input state; it does not impose a UI toolkit.
Focus and pause behavior #
The input snapshot exposes focused. Use it to pause interactions that should not continue when the window loses focus.
// Read focus from the same snapshot as pointer and timing state.
const input = sys.input.get();
// Stop interactive updates while the app is not focused.
if (!input.focused) {
// Draw a calm paused state instead of advancing the simulation.
drawPausedOverlay();
// Keep rendering so the overlay remains responsive when focus returns.
sys.animation.requestFrame(frame);
// Leave the current frame early.
return;
}For games, this small guard prevents accidental movement when the app regains focus. For creative tools, you might still render but stop interpreting pointer drags until focus returns.
State shape #
Budo does not prescribe state management. A plain object is often the best start.
// Keep app state in one plain object while the project is small.
const state = {
// Use a string mode for high-level interaction state.
mode: 'idle',
// Store the current x position of the active object.
x: 120,
// Store the current y position of the active object.
y: 120,
// Store horizontal velocity in pixels per second.
velocityX: 0,
// Store vertical velocity in pixels per second.
velocityY: 0,
// Use -1 when nothing is selected.
selectedId: -1
};As the app grows, split behavior by responsibility rather than by framework convention. A common shape is state.js, input.js, draw.js, and main.js, with main.js staying short enough to reveal the frame flow.
// Import the shared state object.
import { state } from './state.js';
// Import the input-to-state translation layer.
import { updateFromInput } from './input.js';
// Import the drawing layer.
import { drawScene } from './draw.js';
// Keep the frame function short enough to show the app flow.
function frame(timestamp) {
// Read input once and pass the snapshot down.
const input = sys.input.get();
// Mutate state from input and time.
updateFromInput(state, input, timestamp);
// Draw the state after it has been updated.
drawScene(state);
// Continue rendering.
sys.animation.requestFrame(frame);
}Keep runtime handles in state only when their lifecycle is clear. Shader programs, paths, fonts, SVG handles, audio buffers, database handles, and neural model IDs all represent resources managed by the runtime.
Long work and responsiveness #
Frame callbacks should not block on long computations. A blocking loop prevents input polling, network completion callbacks, audio responsiveness, and rendering.
For expensive work you control, break it into chunks and advance it over several frames. For network and file operations, use the runtime APIs as intended. For model inference, consider when a synchronous run is acceptable and when the app should visually communicate that it is working.
The runtime is small by design, which means your app structure matters. Clear frame code is one of the best performance tools you have.