Input

A player's input is what they do in one tick, and it's what the network sends. A game can declare one input, whose fields systems read, or read devices directly in the match.

tide
input PlayerInput
{
    float2 move;
    bool jump;

    Sample(Devices devices)
    {
        var keys = devices.keyboard;
        if (keys.d.pressed) move.x += 1;
        if (keys.a.pressed) move.x -= 1;
        move += devices.gamepad.leftStick;
        jump = keys.space.pressed || devices.gamepad.buttonSouth.pressed;
    }
}

system Jump(PlayerInput input, mut Velocity velocity)
{
    if (input.jump.down)
        velocity.value.y = 5;
}

Click the game below so it has the keyboard, then walk with A and D or the arrows and jump with space. A drag works too: sideways to walk, up to jump. Sample turns what the devices say into the input's two fields, a system moves the hero by them, and the line at the top shows what the match got.

An input, sampled and readLive Tide example, tap it to play
Open separately
tide
settings { title = "Input"; tickRate = 30; maxPlayers = 1; }

// A drag of the pointer, which a view turns into a stick for the input.
local singleton Stick
{
    float2 start;
    float2 drag;
    bool up;
}

// What a player does in one tick. It's the only thing a machine sends the others.
input Pad
{
    [Clamp(-1, 1)] float2 move;
    bool jump;

    Sample(Devices devices, Stick stick)
    {
        var keys = devices.keyboard;
        if (keys.d.pressed || keys.rightArrow.pressed) move.x += 1;
        if (keys.a.pressed || keys.leftArrow.pressed) move.x -= 1;
        move += devices.gamepad.leftStick + stick.drag;
        jump = keys.space.pressed || keys.w.pressed || keys.upArrow.pressed
            || devices.gamepad.buttonSouth.pressed || stick.up;
    }
}

component Hero
{
    float2 position;
    float2 velocity;
    float2 move; // What the views show of the input, which only the match reads
    bool jump;
    int jumps;
}

scene Main { }

// The hero is its player's, so it reads that player's input.
event(PlayerJoined joined) GiveHero()
{
    Spawn(Hero, Owner { player = joined.player });
}

system Walk(Pad pad, mut Hero hero, Time time)
{
    hero.velocity.x = pad.move.x * 3;
    // The input says held. The match works out `down` against the tick before.
    if (pad.jump.down && hero.position.y <= 0)
    {
        hero.velocity.y = 6;
        hero.jumps += 1;
    }
    hero.velocity.y -= 16 * time.dt;
    hero.position += hero.velocity * time.dt;
    if (hero.position.y < 0)
    {
        hero.position.y = 0;
        hero.velocity.y = 0;
    }
    hero.position.x = Math.Clamp(hero.position.x, -1.7, 1.7);
    hero.move = pad.move;
    hero.jump = pad.jump;
}

view Drag(Devices devices, mut Stick stick)
{
    var pointer = devices.pointer;
    if (pointer.press.down) stick.start = pointer.position;
    var pull = pointer.press.pressed ? pointer.position - stick.start : float2(0);
    stick.drag = float2(Math.Clamp(pull.x / 50, -1, 1), 0);
    stick.up = pull.y > 40; // The devices count y up
}

view DrawHero(Hero hero)
{
    Draw.Clear(Color(0.086, 0.086, 0.094));
    Draw.SetCamera(float2(0, 1), 2.2);
    Draw.Line(float2(-2.2, -0.3), float2(2.2, -0.3), 0.04, Color(0.24, 0.25, 0.27));
    Draw.RoundRect(hero.position + float2(0, 0.05), float2(0.5, 0.7), 0.16, Color(0.98, 0.75, 0.14));
    Draw.Line(hero.position + float2(0, 0.6), hero.position + float2(hero.move.x * 0.6, 0.6), 0.06, Color(0.18, 0.83, 0.75));

    Draw.SetScreen();
    Draw.Text($"move.x = {hero.move.x:F1}   jump = {hero.jump}   jumps: {hero.jumps}", float2(16, 14), 16, Color.white);
    Draw.Text("Click here, then A and D or the arrows, and space.\nOn a touchscreen, drag sideways, and up to jump.",
                float2(16, Screen.height - 46), 13, Color(0.64, 0.64, 0.68));
}

Sampling

The engine calls the input's Sample method on the player's own machine, once per tick. Its fields start at their defaults, and Sample assigns them by name, without mut.

Sample runs outside the match. It reads this machine's Devices, and the local singletons it takes as parameters, like Sample(Devices devices, Settings settings), but never the match. It's the place to work out what the match needs from things only this machine knows: an aim direction from the mouse, which is in this machine's window, or anything that depends on its settings.

Who gets which input

An input parameter gives a system the input of the player who owns the entity it's running for. Owner is a built-in component that ties an entity to a player:

tide
Spawn(Body, Owner { player = joined.player });

It's a normal component: it can be read, written, added and removed, and writing it hands control over.

Entities without an Owner, or whose owner is no player the match has a place for (default, or a PlayerID past its maxPlayers), get the server's input, and so do systems that run once per tick. An owner the match has a place for who isn't in it right now is still that player: the entity reads their input, not the server's. That's how the server controls what no player owns. An input parameter doesn't pick entities: add with Owner to only run on owned ones.

Below are two machines in one match. Move the pointer over a frame to steer that machine's player. One system moves every body, and for each it reads the input of whoever owns that body. The gray body has no Owner, so it reads the server's input, which here is player 1's machine's: it follows player 1's pointer, slowly. Trade bodies writes the two Owners the other way round, and from then on each player steers the other's body.

Whose input a system readsLive Tide example, two players, tap it to play
Open separately

Player 1 hosts the match

Player 2 joins it

tide
settings { title = "Owners"; tickRate = 30; maxPlayers = 4; }

scene Main { }

component Body
{
    [Position] float2 position; // Drawn gliding to where a correction put it
    Color color;
    float speed = 2.5;
}
singleton Roster { Map<PlayerID, Entity> bodies; int arrivals; }
command Trade { }

// Where this machine's pointer is in the arena, which its input sends.
local singleton Aim { float2 world; bool moved; }

input Steer
{
    // A place, not something a player does: what another machine guesses of it stays
    [Absolute, Clamp(-3, 3)] float2 target;
    [Absolute] bool steering;

    Sample(Devices devices, Aim aim)
    {
        target = aim.world;
        steering = aim.moved;
    }
}

// A body nobody owns. It reads the server's input: the machine hosting the match.
event(Spawned) Setup(with Main)
{
    Spawn(Body { position = float2(0, 1.4), color = Color(0.64, 0.64, 0.68), speed = 0.8 });
}

// Owner ties an entity to a player, so it reads that player's input.
event(PlayerJoined joined) Arrive(mut Roster roster)
{
    var first = roster.arrivals % 2 == 0;
    roster.arrivals += 1;
    roster.bodies[joined.player] = Spawn(
        Body { position = float2(first ? -1 : 1, 0), color = first ? Color(0.98, 0.75, 0.14) : Color(0.18, 0.83, 0.75) },
        Owner { player = joined.player });
}

event(PlayerLeft left) Depart(mut Roster roster)
{
    roster.bodies[left.player].Destroy();
    roster.bodies.Remove(left.player);
}

// One system for every body. `steer` is the input of whoever owns the one it runs for.
system Follow(Steer steer, mut Body body, Time time)
{
    if (!steer.steering) return;
    var way = steer.target - body.position;
    var step = body.speed * time.dt;
    body.position += Math.Length(way) > step ? Math.Normalize(way) * step : way;
}

// Owner is a component like any other: writing it hands control over.
command(Trade trade, PlayerID sender) TradeBodies(mut Roster roster)
{
    mut PlayerID other = sender;
    foreach (var seat in roster.bodies)
    {
        if (seat.key != sender) other = seat.key;
    }
    if (other == sender) return; // Nobody to trade with
    var mine = roster.bodies[sender];
    var theirs = roster.bodies[other];
    mine.Add(Owner { player = other });
    theirs.Add(Owner { player = sender });
    roster.bodies[sender] = theirs;
    roster.bodies[other] = mine;
}

view Point(Devices devices, mut Aim aim)
{
    Draw.Clear(Color(0.086, 0.086, 0.094));
    Draw.SetCamera(float2(0, 0.2), 2.4);
    aim.world = Draw.ScreenToWorld(Screen.Flip(devices.pointer.position));
    if (Math.LengthSq(devices.pointer.delta) > 0) aim.moved = true;
    GUI.SetTheme(GUITheme { radius = 6 });
    if (GUI.Button(Rect(12, Screen.height - 44, Screen.width - 24, 32), "Trade bodies")) Send(Trade { });
}

// Every body this machine's player doesn't own: the other player's, and nobody's.
view Others(Body body, without ownership)
{
    Draw.Circle(body.position, 0.2, body.color);
}

view Unowned(Body body, without Owner)
{
    Draw.Text("no Owner", body.position + float2(0, 0.3), 0.17, Color(0.64, 0.64, 0.68), Anchor.LowerCenter);
}

view Mine(Body body, with ownership)
{
    Draw.Circle(body.position, 0.2, body.color);
    Draw.WireCircle(body.position, 0.32, 0.04, Color.white);
    Draw.Text("yours", body.position + float2(0, 0.4), 0.2, Color.white, Anchor.LowerCenter);
}

A view picks the entities this machine's player owns with with ownership, and every other one with without ownership (see Your own entities). That's for views: the match runs the same on every machine, so a system has no player of its own to ask about.

Players are PlayerIDs, not ints or connections. Like Entity, a PlayerID is opaque, compared with ==, and has a null value. A player who reconnects gets their PlayerID back, and keeps everything they owned. PlayerID(0) names a player by number, for local play and tests.

Held, not pressed

Input carries whether buttons are held, not whether they just went down: when a player's input is late, the other machines guess it by repeating their last one, and a repeated "just went down" would press twice. In the match, bool fields of the input get .down and .up, worked out against the previous tick, inside structs too: input.aim.fire.down.

What other machines guess

A machine runs ahead of the last tick the server confirmed to it, by about its round trip (see Networking). For those ticks it has to guess what the other players do, and its guess is their last input, repeated. A guess that turns out wrong is put right when the server's answer comes, and the further ahead a machine runs, the more there is to put right: at a ping of 500 ms, a player who stopped running is shown running for half a second more, and then moved back all of it.

So a guess only repeats an input for 0.15 seconds past what the machine knows. After that it takes the player to have let go: every field of their input is back at its default. However slow the connection, a guess that was wrong moves things by what a player does in 0.15 seconds, at most. A machine with a round trip under about a tenth of a second never runs that far ahead, and nothing changes for it.

That suits what a player is doing: an axis that's pushed, a button that's held. A field that says where something is, like an aim or the cell under a pointer, has no rest to go back to: its default is only another place. Mark those [Absolute], and a guess keeps them for as long as it goes on:

tide
input Controls
{
    [Clamp(-1, 1)] float move;   // Doing: back to 0
    bool fire;                   // Doing: let go
    [Absolute] float2 aim;       // Where: kept
    [Absolute] int weapon;       // Which: kept
}
  • Letting go presses nothing. No field's .up is true for it, so a shot that fires when a button is let go doesn't fire on a guess.
  • The devices match code reads (see Devices in the match) are let go the same way: keys up, sticks centered.
  • Only guesses change. What the server runs, and what it confirms, is the input each player sent. A player whose input arrives late still keeps their last one for that tick, on every machine.
  • What it costs. A player who keeps going is shown a little behind where they'll turn out to be, and each tick the server confirms, the machine runs the ticks past its guess again. For the default guess, both happen only on a machine further ahead than 0.15 seconds, and only while someone is doing something. Custom guesses can require replay sooner.

[Absolute] takes no arguments, and goes on the input's own fields, not on the fields of a struct it holds.

Choosing a guess

Use [DontPredict] for actions that should occur only when a real input sample says so. On every guessed tick, the field starts at its declared default:

tide
input Controls
{
    float2 move;
    [DontPredict] bool jump;
    [DontPredict] bool dash;
    [Absolute] float2 aim;
}

Movement continues from the previous input; jump and dash become false immediately. Real input from this machine still acts immediately, and received input keeps its actions. The attribute takes no arguments and goes on the input's own fields. A marked struct or array resets as a whole. It cannot combine with [Absolute] on the same field.

For a computed guess, add Predict():

tide
input Controls
{
    float2 move;
    bool dash;

    Predict()
    {
        dash = false;
        move *= 0.9;
    }
}

It runs once per guessed tick, starting from the previous input, after [DontPredict] resets. Fields it leaves untouched continue; computations such as the movement fade accumulate across guessed ticks. It can also explicitly replace a marked field's default. Repair, bounds and Sanitize() apply afterward. The existing 0.15-second limit still resets non-absolute fields after Predict() runs.

Predict() takes no parameters and reads only the input's fields. It can call pure helpers, as Sanitize() does. It cannot read devices, match or local state, frame time or randomness, or send events, spawn entities or start tasks. The engine's viewTime remains read-only.

These changes apply to client guesses of other players and a remote server. Actual samples, authoritative server ticks and confirmed replay keep their existing behavior. Changing a guess creates no .down or .up edges, so clearing an action does not invent a release. Normal .down already avoids repeated presses when a held bool is repeated; use these hooks when the held value itself or another action field should not continue into guessed ticks.

Devices

Devices has a keyboard, a mouse, a gamepad, a touchscreen and the pointer. Every button has .pressed (held), .down (went down since the last sample) and .up (went up). On a device, .pressed means held at any point since the last sample, so a quick tap between ticks is never lost. A match that starts from a menu doesn't read what was pressed there: its first tick gets what's held as it starts.

  • Keyboard: every key by its position, named after the US layout, so WASD is in the same place on AZERTY:
    • a to z, and digit0 to digit9
    • space, enter, escape, tab, backspace
    • insert, delete, home, end, pageUp, pageDown
    • upArrow, downArrow, leftArrow, rightArrow
    • leftShift, rightShift, leftCtrl, rightCtrl, leftAlt, rightAlt, capsLock
    • f1 to f12
    • minus, equals, leftBracket, rightBracket, backslash, semicolon, quote, comma, period, slash, backquote
    • numpad0 to numpad9, numpadEnter, numpadPlus, numpadMinus, numpadMultiply, numpadDivide, numpadPeriod
    • text, a string: what was typed since the last frame, which views read (see Typed text)
  • Mouse: position, delta and scroll (float2), and the buttons left, right, middle, back and forward. position is in window pixels from the bottom left, with y up, as a touch's and the pointer's are; in a view, Screen.Flip(position) gives it from the top left, as the screen and the GUI count (see Screen and world coordinates). While the cursor is locked, position stays where it was and delta is the mouse's own movement.
  • Gamepad: connected, leftStick and rightStick (float2), leftTrigger and rightTrigger (0 to 1), the face buttons by position (buttonSouth, buttonEast, buttonWest, buttonNorth, which is A, B, X and Y on Xbox), dpad.up and the other directions, leftShoulder, rightShoulder, leftStickButton, rightStickButton, start and select.
  • Touchscreen: connected, primaryTouch, and touches, a slot for each of 10 fingers. Each Touch has:
    • press, a button: .down when the finger touched, and .up when it lifted
    • id, the same while the finger touches, and a new one for each touch
    • position, delta and startPosition (float2)
  • Pointer: position, delta and press: the mouse, with its left button, or the primary touch, whichever was used last. Once a finger lifts, the pointer stays where it lifted, until another finger touches or the mouse is used.

Each part has a type of its own: Keyboard, Mouse, Gamepad, Dpad, Touchscreen, Touch, Pointer and Button. Functions take them, and Devices, as parameters to read:

tide
float2 Steer(Gamepad pad)
{
    return pad.leftStick + float2(pad.dpad.right.pressed ? 1 : 0, 0);
}

Axes follow Unity: y is positive up, for sticks, the mouse, touches and the pointer, and scroll.y is positive scrolling away from you.

Touch

A finger keeps its slot in touches while it touches, and lifts out of it. With ten fingers on the screen, an eleventh is left out. primaryTouch is the finger that touched while no other was the primary one, until it lifts: the one to follow for a single finger. A finger that touches and lifts between two samples reads as held for one, like a quick key press, so no tap is lost.

touches[i] is the touch in slot i (an empty one past the last), touches.count is how many slots there are, and foreach goes through every slot, touching or not:

tide
mut var fingers = 0;
foreach (var touch in devices.touchscreen.touches)
{
    if (touch.press.pressed) fingers += 1;
}

A finger is never the mouse, as in Unity's Input System: the mouse only sees mice. Code that should work with both reads the pointer, which follows the mouse or the primary touch, whichever was used last, as Unity's Pointer.current does. The GUI follows it too, so buttons work with a finger.

tide
input Brush
{
    [Absolute] int x;
    [Absolute] int y;
    bool paint;

    // Cells of 16 pixels, under the mouse or a finger
    Sample(Devices devices)
    {
        var pointer = devices.pointer;
        x = int(Math.Floor(pointer.position.x / 16));
        y = int(Math.Floor(pointer.position.y / 16));
        paint = pointer.press.pressed;
    }
}

Touches come from Android, the web on phones and desktops, and touchscreens on Windows. Native iOS support isn't built yet; other native desktops have no touch support yet. A browser on those desktops can still report touches. Where no touchscreen is available, connected is false and no finger ever touches.

The cell coordinates above are [Absolute] because a prediction should keep the last cell the player pointed at, even after it guesses they stopped painting.

Try a mouse, then a finger in the example below. The amber ring is the pointer; teal circles are individual touches, with lines back to where each started. A touch leaves the mouse where it was, and lifting it leaves the pointer where it ended. The ten slots show which fingers are still held. Click or tap the frame first so swipes go to the example.

The pointer and individual fingersLive Tide example, tap it to play
Open separately
tide
settings { title = "Pointer and touch"; }

local scene Main { } // Device inspection needs no match

local singleton Trace
{
    int presses;
    float flash;
    float2 last;
    string edge = "Move the pointer, then press";
}

view Inspect(Devices devices, mut Trace trace)
{
    Draw.Clear(Color(0.086, 0.086, 0.094));
    Draw.SetScreen();
    var quiet = Color(0.64, 0.64, 0.68);
    var amber = Color(0.98, 0.75, 0.14);
    var teal = Color(0.18, 0.83, 0.75);
    var pointer = devices.pointer;
    var at = Screen.Flip(pointer.position); // Devices count from the bottom left

    trace.flash = Math.Max(0, trace.flash - Frame.dt);
    if (pointer.press.down)
    {
        trace.presses += 1;
        trace.last = at;
        trace.flash = 0.7;
        trace.edge = "down: the press began";
    }
    if (pointer.press.up) trace.edge = "up: the press ended";

    Draw.Text("Pointer and touch", float2(16, 14), 20, Color.white);
    Draw.Text($"held: {pointer.press.pressed}    presses: {trace.presses}",
        float2(16, 44), 14, amber);
    Draw.Text(trace.edge, float2(16, 68), 13, quiet);

    // A touch never moves the mouse. Its outline stays at the mouse's position.
    Draw.WireCircle(Screen.Flip(devices.mouse.position), 9, 2, quiet);
    mut var held = 0;
    for (var i = 0; i < devices.touchscreen.touches.count; i++)
    {
        var touch = devices.touchscreen.touches[i];
        var center = float2(16 + (i + 0.5) * (Screen.width - 32) / 10, Screen.height - 88);
        Draw.Circle(center, 10, touch.press.pressed ? teal : Color(0.24, 0.25, 0.27));
        Draw.Text($"{i}", center, 12, Color.white, Anchor.MiddleCenter);
        if (!touch.press.pressed) continue;

        held += 1;
        var finger = Screen.Flip(touch.position);
        Draw.Line(Screen.Flip(touch.startPosition), finger, 2, teal);
        Draw.Circle(finger, 12, Color(teal.r, teal.g, teal.b, 0.3));
        Draw.Text($"id {touch.id}", finger + float2(16, 0), 13, teal);
    }

    Draw.WireCircle(at, pointer.press.pressed ? 20 : 15, 3, amber);
    if (trace.flash > 0)
        Draw.WireCircle(trace.last, 20 + (0.7 - trace.flash) * 60, 2,
            Color(amber.r, amber.g, amber.b, trace.flash / 0.7));

    Draw.Text($"{held} fingers held; slots 0 to 9", float2(16, Screen.height - 62), 13, quiet);
    Draw.Text("Amber: pointer   Teal: touch   Gray: mouse\nTap here first, then try dragging several fingers.",
        float2(16, Screen.height - 40), 12, quiet);
}

Locking the cursor

A camera that turns with the mouse locks the cursor: a view calls Cursor.Lock() every frame the game wants the mouse (see Looking around). Cursor.locked is whether the window holds it right now, which the system decides, not the game: the cursor goes back to the player when they press Escape, and a window that loses the focus lets it go.

Sample reads Cursor.locked, so the mouse only turns the camera while the cursor is locked, and moving it over a menu doesn't:

tide
input PlayerInput
{
    float2 look;

    Sample(Devices devices)
    {
        if (Cursor.locked) look = devices.mouse.delta * 0.003;
    }
}

Sample can't call Cursor.Lock(): it runs once per tick, and the lock is asked for every frame, by views. The match can't read Cursor.locked, as it can't read anything of this machine's: put what it needs in the input, as look is here.

Typed text

devices.keyboard.text is what the player typed since the last frame, as a string. Unlike keys, it follows the keyboard's layout: on AZERTY, the key in the q position types a, and text says a. Shift, accents and a phone's keyboard are applied, and what's pasted with Ctrl+V comes through it too. Backspace, Enter and the arrows are keys, not characters.

A text field of the GUI's reads it for you. Views read it themselves, and so do the functions they call, for a text box of their own, or to pass on to a library:

tide
local singleton Chat
{
    string line;
}

view Typing(Devices devices, mut Chat chat)
{
    chat.line += devices.keyboard.text;
    if (devices.keyboard.backspace.down) chat.line = chat.line.Substring(0, chat.line.length - 1);
}

It's a frame's, so Sample can't read it, and neither can the match: an input holds no text, and what's typed is never sent. It's empty while the GUI has the keyboard, as when the player types into a TextField, and in every view but the one whose control has the focus, or that claimed the keyboard. A long paste comes over a few frames, 32 characters in each.

Devices in the match

Match code can read devices too, with a Devices parameter: the devices of the player who owns the entity, like an input parameter. The input then sends what match code reads of them, and nothing else. A game can do without an input declaration this way:

tide
system Hop(Devices devices, mut Velocity velocity)
{
    if (devices.keyboard.space.down || devices.gamepad.buttonSouth.down)
        velocity.value.y = 5;
}

Device access always takes a Devices parameter. Sample and views receive this machine's devices; match code receives the entity owner's, or the server's. A system takes at most one Devices parameter and one input parameter. Views can't read the input. Local event handlers and tasks can't take devices.

The match can't read positions on the screen either, the mouse's, a touch's or the pointer's: they're in this machine's window, which the other machines don't have. Work out what the match needs from them in Sample, like an aim direction, and read that from the input.

Input is an attack point

Inputs come from other players' machines, so the engine doesn't trust them:

  • NaN and infinite floats become the field's default.
  • Fields can declare bounds, [Clamp(lo, hi)], [Min(x)] and [Max(x)], which the engine applies to every input. A number bounds every component of a vector.
  • Then the input's Sanitize method runs, if it has one, for what bounds can't say.
tide
input PlayerInput
{
    [Clamp(-1, 1)] float2 move;
    [Min(0), Max(3)] int gear = 1;
    bool boost;

    Sample(Devices devices) { move = devices.gamepad.leftStick; }

    // After the bounds: what attributes can't say.
    Sanitize()
    {
        if (gear == 0) boost = false;
    }
}

Every input goes through these steps before the match reads it, including this machine's, so systems can rely on them without checking again. Devices are repaired the same way, since the engine knows their ranges.

An input holds numbers, bools, chars, enums, vectors and structs of those; not text or lists. A match takes as many players as the game's maxPlayers setting says, 16 without one (see Matches).

Presented view time

With settings { maxRewind = 0.25; }, your input gains ViewTime viewTime; do not declare this field yourself. It is read-only and stamped by the engine after Sample, from the last actually presented confirmed frame. tick names the upper endpoint, alpha its interpolation fraction, and valid is false before a frame is presented. Use it with collider rewind; the server validates the wire value as an untrusted claim.