Docs / Learn / Run a match

Run a match: the input and state loop

Goal: close the loop - a click on one client moves your fighter, and every client in the arena round sees it move.

This is the payoff. You have two clients in one arena round (step 7). Now wire the loop:

  1. Client sends intent with send_match_input.
  2. Server (match.lua) decides and moves your fighter.
  3. Server broadcasts the new state as match.state.
  4. Every client renders it.

The client never moves your fighter itself. It asks; the server decides; the server tells everyone. See the websocket-protocol guide for the full match.* wire contract.

The server tick (Lua)

This is identical on Cloud and self-hosted - it is your game bundle, and the bundle does not change between deployments. Write it once.

Your fighter lives in match state. handle_input applies a move intent. get_state is the per-player view that the server pushes as match.state every tick (matches run at 10 Hz by default).

guest_auth = true

local W, H = 16, 16

function init(config)
    return { fighter = { x = 8, y = 8 } }
end

function join(player_id, state)
    return state
end

function leave(player_id, state)
    return state
end

function handle_input(player_id, input, state)
    local d = state.fighter
    d.x = math.max(0, math.min(W - 1, d.x + (input.move_x or 0)))
    d.y = math.max(0, math.min(H - 1, d.y + (input.move_y or 0)))
    return state
end

function tick(state)
    return state
end

function get_state(player_id, state)
    return { arena_w = W, arena_h = H, fighter = state.fighter }
end

The client sends { move_x = 1, move_y = 0 }; the server clamps it to the arena and moves your fighter. The arena is 16 by 16. Nothing about position is trusted from the client. Callback shapes are documented in the lua-scripting guide.

Hot-reload picks up edits to this file between ticks, so you can tweak the movement rule without restarting.

The client (send + receive)

Every SDK does the same two things: register the match.state handler, then send input on a click. Register the state handler BEFORE you join - a state push can arrive the instant you are in, and a handler set afterwards misses it.

Connect and join were done in steps 3 and 7; only the base server URL differs between Cloud and self-hosted, and you set that once when you construct the client. The calls below are otherwise identical on both.

  • Cloud: base URL is your environment URL from console.asobi.dev.
  • Self-hosted: base URL is your own host on port 8084.

Send one input per click, mapping the click direction to move_x / move_y in {-1, 0, 1}.

Unity: OnMatchState hands you the raw JSON envelope string; parse it yourself. SendMatchInputAsync takes a JSON string and is fire-and-forget.

Unreal: Bind the delegate before JoinMatch. The handler must be a UFUNCTION and receives the raw JSON string.

Dart/Flame: onMatchState is a broadcast stream of typed MatchState payloads. sendMatchInput is fire-and-forget; do not await it.

JavaScript: The JS client uses raw wire names. Join is an awaited RPC (send); input is fire-and-forget (sendFire) and must NOT be awaited.

LOVE: Mapped callback names, and a manual pump: client.realtime:update() must run every frame or no callbacks fire.

local rt = client.realtime

rt:on("match_state", function(state)
    render(state.fighter)
end)

rt:join_match(match_id)

-- on a click
rt:send_match_input({ move_x = 1, move_y = 0 })
Asobi.realtime.match_state.connect(_on_state)
Asobi.realtime.join_match(match_id)

func _on_state(payload: Dictionary) -> void:
    render(payload["fighter"])

# on a click
Asobi.realtime.send_match_input({ "move_x": 1, "move_y": 0 })
client.Realtime.OnMatchState += rawJson =>
{
    var state = JsonUtility.FromJson<GridState>(rawJson);
    Render(state.fighter);
};

await client.Realtime.JoinMatchAsync(matchId);

// on a click
await client.Realtime.SendMatchInputAsync("{\"move_x\":1,\"move_y\":0}");
WebSocket->OnMatchState.AddDynamic(this, &UMyClass::HandleMatchState);
WebSocket->JoinMatch(MatchId);

// UFUNCTION handler
void UMyClass::HandleMatchState(const FString& StateJson)
{
    Render(StateJson);
}

// on a click
WebSocket->SendMatchInput(TEXT("{\"move_x\":1,\"move_y\":0}"));
client.realtime.onMatchState.stream.listen((MatchState state) {
  render(state);
});

await client.realtime.joinMatch(matchId);

// on a tap
client.realtime.sendMatchInput({'move_x': 1, 'move_y': 0});
ws.on("match.state", (payload) => {
  render(payload.fighter);
});

await ws.send("match.join", { match_id });

// on a click
ws.sendFire("match.input", { data: { move_x: 1, move_y: 0 } });
client.realtime:on("match_state", function(state)
    render(state.fighter)
end)

client.realtime:join_match(match_id)

-- on a click
client.realtime:send_match_input({ move_x = 1, move_y = 0 })

function love.update(dt)
    client.realtime:update()
end

Checkpoint

Run two clients joined to the same arena round.

  1. Click a direction on client A.
  2. Client A sends send_match_input; the server moves your fighter and broadcasts match.state.
  3. Your fighter moves on BOTH client A and client B.

If it moves on A but not B, both clients are not in the same arena round - recheck step 7. If it moves on neither, the state handler was registered after join, or (LOVE) the per-frame update() pump is missing.

Next: Step 9 - End a match

signal _finished with a result table and receive match.finished on every client.