Each host cycle, update! receives an App.Input describing what
happened since the previous cycle. This chapter covers the keyboard,
mouse, gamepads, typed text, time, the window, and dropped files. It assumes
[app-model].
An input contains two different kinds of information, and the difference matters when you read it:
-
A state sample is the latest value at the moment the cycle began: whether a key is held, where the pointer is, how far a gamepad stick is pushed.
-
An interval event is something that happened since the previous input: a key press, a release, a click, a wheel notch, a typed character.
A key tapped quickly between two cycles is never held at a cycle boundary, but the press and the release still arrive as events. RocRay records keyboard, mouse-button, wheel, and text events as the window system delivers them, so every such event appears in the next input; none is silently lost.
Read keys through the devices snapshot:
devices = input.devices
jump = devices.key_pressed(KeySpace) # went down this cycle
running = devices.key_down(KeyLeftShift) # held right now
released = devices.key_released(KeyW) # came up this cycleKeys are tags such as KeySpace, KeyW, KeyLeft, and KeyEnter; the
Keys page of the API reference lists them all.
key_pressed and key_released mean "at least once since the previous
input". Several presses of the same key in one interval count as one. When the
count or the order matters, read devices.events instead (see
The ordered event record). A double tap is one example. Text editing is another,
where a key press can come between two typed characters.
By default, Escape is the exit key: it ends the app before update! sees
the press, so you do not check for it yourself. An app that wants Escape for
something else, such as a pause menu, turns that off with
App.default.with_exit_key(NoExitKey). The window’s close button still ends
the app; [app-model-closing] shows how to handle it yourself.
devices.text_input holds the Unicode codepoints typed since the previous
input, in the order typed and according to the user’s keyboard layout. Use it
for a name entry box or a chat line, rather than working out characters from
key presses. Up to 32 codepoints are delivered per input; if more were typed,
the rest are discarded and devices.text_input_overflow is set.
mouse = input.devices.mouse
pointer = mouse.position() # { x, y } in window coordinates
held = mouse.button_down(Left)
clicked = mouse.button_pressed(Left)
scroll = mouse.wheel_delta() # every notch this interval, summedPositions use the same logical coordinates as drawing: x grows to the right
and y grows downward, starting from the window’s top-left corner.
mouse.delta() is the movement since the previous input.
Change the cursor’s shape with Mouse.set_cursor! and its mode with
Mouse.set_cursor_mode!(Hidden) or Mouse.set_cursor_mode!(Locked). Both are
legal in init!, update!, and tasks.
The host samples up to four gamepads once per cycle. They are named
One, Two, Three, and Four, following the operating system’s numbering;
a single controller is almost always One. input.devices.gamepad(id)
returns Connected(pad), where pad is a view you can query, or
Disconnected:
stick = match input.devices.gamepad(One) {
Connected(pad) => pad.left_stick()
Disconnected => { x: 0, y: 0 }
}Buttons use layout-neutral names: FaceDown is the bottom face button,
whatever its label says. So pad.button_pressed(FaceDown) is "jump" on any
controller.
Gamepad buttons are state samples only. The host compares this cycle’s
sample with the previous one to compute button_pressed and
button_released, so a press and release that both happen between two
samples is not seen. At 60 cycles a second, that is a tap shorter than about
17 milliseconds. This is rare for a deliberate press, but it can happen when a
player presses a button very rapidly, or when the frame rate drops. For
anything that must never be missed, use a held button, not a single press or
release. Gamepads never
appear in devices.events.
devices.events lists every key press and release, click, wheel notch, and
typed character
since the previous input, in the order they arrived, across all of those
sources:
Event : [
KeyPressed(Keys.Key),
KeyReleased(Keys.Key),
ButtonPressed(Mouse.Button, { x : F32, y : F32 }),
ButtonReleased(Mouse.Button, { x : F32, y : F32 }),
Wheel({ x : F32, y : F32 }),
Text(U32),
]Each click includes the pointer position where it happened. The list holds up
to 256 events per input; if more arrive, the rest are discarded and
devices.events_overflow is set. The combined values (key_pressed, the
wheel sum, text_input) are still updated when the list is full.
input.time describes this cycle:
-
elapsed_secondsis how much time passed since the previous cycle, in seconds. The first cycle reports0. -
cycle_countcounts cycles from zero.
Move things by speed times elapsed time, and they move at the same speed whatever the frame rate. With plain records:
dt = input.time.elapsed_seconds
position = {
x: model.position.x + model.velocity.x * dt,
y: model.position.y + model.velocity.y * dt,
}The Math module has the same arithmetic ready-made. Make a vector with
Math.vec2(x, y) and store it as a Math.Vec2; its methods add, sub,
scale, length, and normalize then read as one line:
# With position : Math.Vec2 and velocity : Math.Vec2 in the model:
position = model.position.add(model.velocity.scale(dt))Time.now!() reads the wall clock as a UTC Time.Timestamp, for things like
naming a save file. It does not wait, and it is legal in init!, update!,
and tasks. Use input.time, not the wall clock, for animation and movement.
elapsed_seconds is the real time between two cycles, and it varies. A
cycle is usually a few milliseconds, but the operating system decides: while
the user drags the window, or while you pause the app in a debugger, no
cycles run, and the next cycle reports all the time that passed, possibly
several seconds. The host reports that value as it is, without limiting it,
because only the app knows how it should handle a long pause. So a simple
position + velocity * dt can move a player straight through a wall.
The simplest protection is to clamp the elapsed time before you use it, so one cycle never advances the game by more than a limit you choose. Paddle in [first-game] uses a twentieth of a second, and most examples use a quarter:
dt = Math.clamp(input.time.elapsed_seconds, 0, 0.25)Physics and collision work best with a fixed step: the game always advances by exactly the same small amount, say 1/60 of a second, and runs as many of those steps each cycle as fit in the elapsed time. Because every step is the same size, the result does not depend on how fast the computer is or how uneven the cycles are: the same input always moves things the same way, and no single step is large enough to carry an object through a wall.
Leftover time is kept in the model for the next cycle. Each of those advances is a simulation step, and one host cycle may run zero, one, or several of them:
step_seconds : F32
step_seconds = 1 / 60
## Run as many whole steps as fit in `banked`, the time saved so far,
## and return the time left over.
run_steps : World, F32 -> (World, F32)
run_steps = |world, banked| {
if banked < step_seconds {
(world, banked)
} else {
run_steps(simulate(world), banked - step_seconds)
}
}
# In update!:
dt = Math.clamp(input.time.elapsed_seconds, 0, 0.25)
(world, accumulator) = run_steps(model.world, model.accumulator + dt)
Ok({ ..model, world: world, accumulator: accumulator })Here World is the part of your model that the game logic updates, and
simulate is
your own pure function that advances it by one step.
Snake moves its snake this way. [architecture]
explains the difference between a host cycle and a simulation step.
Some runs report a fixed elapsed_seconds rather than a measured one. A
headless run (see [testing]) reports exactly 1/60 of a second every cycle,
and a recording with FixedStep timing reports exactly one recording frame’s
worth (see [capture]). Those are the runs you can repeat and get the same
result, provided the input, the random seed, and any task results are the same
too.
Keep a Random.State in the model and take random values from it with pure
generators, so a run can be reproduced from its seed. Seed it once in init!, from
io.entropy!() for a run that should vary, or from a constant for one that
must reproduce:
# In init!:
rng = Random.seed(U64.to_u32_wrap(io.entropy!()))Each call to Random.step returns a value and the next state. Keep the
state, or the next call returns the same value again:
x = Random.step(model.rng, Random.f32(0, 800))
Ok({ ..model, rng: x.state, star: { x: x.value, y: 0 } })Random.f32(low, high) gives an F32, ready to use as a position. The integer
generators, such as Random.bounded_i32(0, 9), give integers; convert one
before mixing it with positions, for example I32.to_f32(column.value) * 60.
input.window reports:
-
size, the window’s{ width, height }; -
focused, whether it has keyboard focus; -
minimized, whether it is minimized. A minimized window still runs cycles, so an app that should pause while minimized checks this. -
close_requested, whether the user asked to close it this cycle. It is only ever set when the app asked for close requests withwith_close_request(Deliver); see [app-model-closing], which shows how to save before the app ends.
The size is in logical units: the same units as mouse coordinates and
drawing. On a high-DPI display, such as a Retina screen, the window has more
physical pixels than logical units, so that things are not drawn tiny.
Window.scale!() returns the factor between the two on each axis, for example
{ x: 2, y: 2 }. Most apps never need it.
Window effects are legal in init!, update!, and tasks:
-
Window.suggest_size!,Window.suggest_min_size!,Window.suggest_position!, andWindow.suggest_monitor!ask the window manager for new geometry. They are namedsuggestbecause the window manager may change or ignore the request; the next input’swindowfield shows the actual result. -
Window.set_target_fps!changes the frame-rate cap; see below. -
Window.monitors!()lists the connected monitors.
Frame pacing decides how often cycles run. Choose it at startup with
with_frame_pacing:
-
Capped(fps)runs at mostfpscycles a second.App.defaultusesCapped(240). A value of zero or less meansUncapped. -
VSyncwaits for the display’s refresh, usually 60 times a second, which avoids tearing. -
Uncappedruns as fast as the machine allows.
Window.set_target_fps!(fps) changes the cap while the app runs, for example
to slow down while minimized. It sets the same cap as Capped, and a value of
zero or less removes it. It does not turn VSync on or off: if the app started
with VSync, the display still limits the rate, and the app runs at whichever
of the two is slower.
Whatever the pacing, read elapsed_seconds rather than assuming a rate.
When the user drops files on the window, input.dropped lists them for that
cycle, each with its path and the pointer position where it landed, and
input.dropped_overflow is set if more arrived than the host keeps.
A dropped path is only information; it does not give the app access to the
file. To read the file, turn the path
into a handle in the same update! that received it; see
[files-designated].
-
Input Inspector shows every device snapshot live.
-
Responsive Settings handles resizing, display scaling, and monitor placement.
-
The
Devices,Keys,Mouse,Gamepad,Window,Time,Math, andRandompages of the API reference. -
The input guarantees are stated precisely in [architecture].