What this proves
The multiplayer surface stays tiny on the client. The game rules stay small and readable on the server.
Real code from Tic-Tac-Toe
This page uses real snippets from the Tic-Tac-Toe demo in this repo. Read it row by row: what the client does, what the room does in response, and how the multiplayer loop stays understandable.
The multiplayer surface stays tiny on the client. The game rules stay small and readable on the server.
Everything below is adapted from the actual Tic-Tac-Toe client and room logic, not from a made-up toy example.
You do not spin up your own WebSocket stack just to start a two-player game and keep it authoritative.
Client to server, step by step
<script src="https://lungo.minmax1996.me/sdk/game-sdk.js?v=20260329-1"></script>
const sdkConfig = {
masterUrl: window.config.masterUrl,
authUrl: window.config.authUrl || "",
};
window.tictactoeSdk = new window.GameSDK.GameSDK(sdkConfig);
Create or join a room. Lungo gives you a connect string, and the room creates persistent state that can be synced back after reconnect or rejoin.
async joinOrCreate(isOpen) {
const connectString = await window.tictactoeSdk.joinOrCreate(isOpen, {
name: "tictactoe",
maxPlayers: 2,
logicParams: { game_mode: "classic" },
});
await window.tictactoeSdk.connect(
connectString,
window.tictactoeSdk.getToken()
);
}
local function getGameState()
local state = getState("game")
if state == nil then
state = {
board = {},
current_player = "X",
game_active = false,
user_map = {}
}
setState("game", state)
end
return state
end
Subscribe to the events you care about. When the room fills up, the server emits `start_game`, and every connected client receives the same authoritative payload.
window.tictactoeSdk.onGameAction("start_game", (action) => {
this.board = action.data.message.board;
this.currentPlayer = action.data.message.current_player;
this.updateBoard(this.board);
});
window.tictactoeSdk.onGameAction("make_move", (action) => {
const message = action.data.message;
this.board = message.board;
this.currentPlayer = message.current_player;
this.myTurn = message.current_player === this.mySymbol;
this.updateBoard(this.board);
});
window.tictactoeSdk.onGameAction("game_over", (action) => {
this.gameActive = false;
this.updateBoard(action.data.message.board);
});
function onRoomFull(rid, users)
local state = getGameState()
closeRoom()
state.user_map = users
state.game_active = true
initializeBoard(state)
state.current_player = "X"
setState("game", state)
sendMessage(-1, "start_game", {
users = users,
current_player = state.current_player,
board = state.board
})
end
When the player clicks, the client sends a tiny action. The room validates it, mutates state, and broadcasts the next frame or final result.
window.tictactoeSdk.sendGameAction("make_move", {
position: index
});
function onRoomAction(message)
local state = getGameState()
local actorIndex = message.actor_index
if message.action_type ~= "make_move" then
return "unexpected action type"
end
-- Lua arrays are 1-indexed, so client position 0 becomes board index 1.
local pos = message.message.position + 1
if getPlayerSymbol(actorIndex) ~= state.current_player then
return "not your turn"
end
if state.board[pos] ~= "" then
return "position already taken"
end
state.board[pos] = state.current_player
local winner = checkWinner(state.board)
if winner ~= "" or isBoardFull(state.board) then
sendMessage(-1, "game_over", {
winner = getWinnerActorIndex(state.user_map, winner),
board = state.board,
is_draw = winner == "" and isBoardFull(state.board)
})
else
state.current_player = state.current_player == "X" and "O" or "X"
sendMessage(-1, "make_move", {
board = state.board,
current_player = state.current_player
})
end
setState("game", state)
return nil
end
How it feels in practice
Lungo keeps the protocol and room lifecycle consistent, so your code mostly describes the game, not the infrastructure.
Point the client to `masterUrl` and `authUrl`, then instantiate `GameSDK` once.
Call `joinOrCreate`, get a connect string back, then connect with the player token.
Decide what happens on room full and on each action. Validate, mutate state, broadcast.
The client just listens for `start_game`, `make_move`, `game_over` and updates the board.
What stays small
The demo remains compact because transport, room wiring, and player session handling are already part of the platform.
No custom socket protocol, no hand-written reconnect loop, no server bootstrap logic in the frontend.
The Lua script reads like pure game logic: turns, valid moves, winners, and one broadcast per state change.
Turn-based games, board games, cards, async duels, and compact PvP interactions map especially well to this pattern.