Skip to article Developer portalAnnounce, upload and sell your game

GAMENIGHT / DOCUMENTATION

Controllers & players

A controller belongs to a seat through its host token. A profile belongs to a player through its ID. Those associations must survive switching games and reconnecting devices.

Match tokens, not array positions

For each local seat, find the controller_frame.controllers entry whose controller equals seat.controller. Look up the profile using seat.occupant.player_id. Neither list order nor a token such as ordinal:2 tells you an SDL, Godot or engine device index.

The host frame uses these fields:

pub struct ControllerState {
    pub controller: String,
    /// left x/y, right x/y, left/right trigger; signed values / 32767.
    pub axes: [i16; 6],
    /// A B X Y LB RB Back Start LS RS Up Down Left Right (bits 0..13).
    pub buttons: u32,
}

Source: crates/gamenight-protocol/src/lib.rs

Axes are left X/Y, right X/Y and left/right trigger. Divide signed axis values by 32767. The button bits are A, B, X, Y, LB, RB, Back, Start, LS, RS, Up, Down, Left and Right, beginning at bit zero.

Release stale input

An empty frame releases every controller. A missing token releases that device. More than 250 ms since the last frame means neutral input. Never borrow another device to fill a gap.

The LÖVE runner already applies this rule:

function M.updateHost(controllers)
    local previous={}
    for _,pad in ipairs(M.hostPads) do previous[pad.controller]=pad;pad.connected=false end
    local nextPads,seen={},{}
    for _,state in ipairs(controllers or {}) do
        if type(state.controller)=='string' and not seen[state.controller] then
            seen[state.controller]=true
            local pad=previous[state.controller] or {controller=state.controller}
            pad.axes,pad.buttons,pad.at,pad.connected=state.axes or {},state.buttons or 0,love.timer.getTime(),true
            function pad:isConnected() return self.connected and love.timer.getTime()-self.at < .25 end
            function pad:isGamepad() return true end
            function pad:getGamepadAxis(axis)
                if not self:isConnected() then return 0 end
                return (self.axes[axisIndex[axis]] or 0)/32767
            end
            function pad:isGamepadDown(...)
                if not self:isConnected() then return false end
                for _,button in ipairs({...}) do
                    local index=buttonIndex[button]
                    if index and math.floor(self.buttons/2^index)%2==1 then return true end
                end
                return false
            end
            nextPads[#nextPads+1]=pad
        end
    end
    M.hostPads=nextPads
    if M.players then M.bind(M.players,nextPads) end
end

Source: games/love-party/shared/input.lua

AI seats receive bots. Empty seats receive no controllable pawn. Keep standalone device enumeration separate from managed input.

Joining and changing profiles

After Prepare, send participation before Ready. Set instant_join only if the match can accept new players immediately. Handle party_updated without resetting the running match. Update names, clothing, skin and face artwork independently.

controller_input reports activity for presence and joining. It does not send movement to the game. Replacement lobbies use runtime-owned sampling; the legacy platformer publishes its own authenticated frames. Games consume the same frame format in both modes. See Build a lobby.

Back must toggle once

Request the lobby with request_overlay. Use press/release edges and the shared one-second release gate. The same held press must not reopen the game after focus changes. Never issue Resume or request_start from a focus callback.

Check real devices

Test two different profiles, reversed connection order, unplugging one pad and reconnecting it. Then switch between games. Synthetic frames can check token matching, but they do not prove that a physical controller belongs to the right player on your machine.