Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

scheme

The live set of activated input maps, and the per-frame dispatch that turns their bindings into the events a controller subscribed to. Backs Zin.scheme.

Nothing is live until something activates a map

A controller holds an inputMap ref, activates it when it takes control, and releases it when it gives control up. The live binding set is exactly the union of what current holders asked for, so an empty world has an empty input surface and an empty screen — no scheme is active by default, and nothing appears on a phone that no component consumes.

For a component that drives the thing it lives on, taking control and being awake are the same moment, so awake / onDestroy are the hooks. For anything a player enters and leaves — a car, a turret, a menu — they are not: the car is still there after the player gets out, so the map goes live on entry and stands down on exit.

public = {
    inputMap = Field.assetRef("inputMap", "@builtin::inputMaps.default", NoSync, "The controls this component reads."),
    speed = Field.number(5, NoSync, "World units per second at full deflection."),
    jumps = Field.number(0, NoSync, "How many times the jump control was pressed."),
}

local controls

function awake()
    controls = public.inputMap:activate()

    controls.move:onInput(function(v, dt, active)
        if not active then return end
        local p = public.entity.position
        local step = public.speed * dt
        public.entity.position = { p.x + v.x * step, p.y, p.z - v.y * step }
    end)

    controls.jump:onPressed(function() public.jumps += 1 end)
end

function onDestroy()
    public.inputMap:deactivate(controls)
end

What activate returned is this holder's claim on the map. Keep it and hand it back: releasing the claim disconnects the subscriptions made through it, and the map stays live for whoever else holds it.

A claim also ends with the context that opened it. A component's claim is released when that component instance goes, whether or not it writes an onDestroy: the component is removed from its entity, the entity despawns, or the component hot-reloads (its new life's awake activates again). A claim a module body opens ends at the module's next run. scopes.list() names each standing claim as a luau row under the context it follows, and scopes.release(tag) ends it with everything else that context holds.

Several maps compose by all being live

The player's surface is the union of every live map, which is what a game needs: picking up a gun activates a weapon map and Fire appears beside the movement stick; dropping it takes away Fire and nothing else. Two controllers that each activate a map with a movement stick produce two sticks — visible, attributable to the second controller, and the importer's to resolve.

Two components activating the same map read one set of controls, each through its own claim. A claim's release disconnects what that holder subscribed and leaves the other holders' handlers attached; the map leaves the live set when its last claim goes. Zin.scheme.live() reports each live map's holders — how many claims are still standing on it — and heldBy, which says who each of them is: kind ("component" or "execute"), entity, source as the chunk:line the activation was opened at, and the label the caller passed to activate("weapon-swap").

An activation reads the map's children as they stand now

Every activate re-reads the map's <name>.inputBinding/ children, so a control added, edited or removed since the map came up reaches the running set on the next activation. The controls are edited in place, so it reaches the holders already standing on the map too: their handles keep working and every subscription they made carries on across the edit.

A map can stand another one down

Composition cannot say "not while this is happening". A map declares the group its controls belong to and the groups it suppresses while live:

-- driving.inputMap/init.luau
return { group = "vehicle", suppresses = { "player" } }

Every live map in a suppressed group reads nothing and draws nothing for as long as the suppressor is live. It is not released: it keeps its holders, its handles and every subscription, and resumes untouched when the suppressor goes. So a car stands down walking without ever finding, owning or releasing the walk map — and a menu stands down the whole game with one declaration.

A map that declares no group belongs to one named after itself, so it collides with nothing and can still be named by something that wants to stand it down. Declaring neither field composes with everything.

The drop frame runs on the tick a map stands down, so a stick held at that moment reports its neutral value once and whatever it drove stops rather than sticking.

Dispatch

_advance(dt) evaluates every live binding once per tick, across every device class it carries — a tablet with a keyboard attached and a desktop with a pad plugged in both answer to whichever the player actually used, rather than to a nominated primary.

EventFiresCadence
inputOnce while the binding is active with (value, dt, true), and once more on the frame it goes inactive with the neutral value and falsePer engine frame
pressed / releasedA button's edgesPer engine frame
changedWhen the value differs from the previous tickPer tick

input firing on the drop frame is what lets one handler both start and stop the motion it drives, without a second subscription to notice the release.

A frame carries more than one tick whenever a host calls Zin.tick beside the tick the engine already runs, or fast-forwards several ticks to converge smoothing. Evaluation follows the tick, so the smoothing lands and changed reports each step of it. input, pressed and released are stamped with the engine frame they were reported for, so the frame carries one of each however many ticks ran in it. Both edge sources hold this frame's edge for as long as the frame lasts: the input snapshot's pressed flag, and Zin.virtual's previous-frame button table. That is what a second reporting pass reads as another press.

Zin.scheme.fired's count is the number of engine frames a control was active since it last cleared, so a button held across two frames reads 2 for one press. The pressed / released subscriptions are what count edges.

A binding that declares a context is live only while that context is on top, the same activity rule actions apply.

An axis1 / axis2 control's own deadzone, curve, invert and smoothing are applied here, in that order, to the reading its classes produced — after as / unitsPerSecond / scale have brought every class into the control's unit, so one deadzone written in that unit covers the stick, the mouse and the finger together. A deadzone is per value on an axis1 and radial on an axis2. The shaping maths is Zin.utils', shared with the axis registry.

smoothing is the one piece of the four that is stateful: the value is held per control across frames and approaches whatever the devices report next over the declared time constant. A control that leaves its context, whose group stands down, or whose gate closes has a neutral target, so it drains rather than cutting — and reports its drop frame when it arrives.

Validation happens before anything registers

Activation reads every child binding, collects every fault across all of them, and raises once with the lot. A map with three broken bindings names all three rather than one per attempt, and a map that fails validation registers nothing — a half-live map is worse to debug than one that refused to come up.

Exports

  • M.activate(mapRef, label?) -> { [name]: Handle } — activate a map, return its handles. label names this activation in M.live().
  • M.deactivate(mapRef) -> boolean — release a claim on a map.
  • M.live() -> { { guid, name, group, suppresses, suppressedBy, holders, heldBy, bindings } } — every live map in activation order. What tells an author why two sticks are on screen, or why the controls they activated answer to nothing, or who is holding the map standing them down.
  • M.bindings() -> { { map, group, name, label, kind, context, suppressedBy, subscribers, record } } — every live binding in activation order. The touch overlay builds from this, so what is on screen is exactly what some awake component asked for and nothing is standing down.
  • M.suppressed() -> { [group]: mapName } — which groups are standing down and what put each one down.
  • M.has(name) -> boolean — whether a named control is live anywhere.
  • M._advance(dt) — one tick of dispatch; wired into Zin.tick.
  • M._childBindings(mapRef) -> { ref } — the map's .inputBinding children, in name order.
  • M._reset() — test-only: drop every live map without firing anything.

Types:

  • Handle = { onInput, onPressed, onReleased, onChanged, label, kind } — a thin view over the binding asset's own event facade, so a subscription made through a handle is the same one ref.events.input:connect would make and ends the same way.