Files
GDevelop/Extensions/Multiplayer/multiplayertools.ts

2064 lines
67 KiB
TypeScript

namespace gdjs {
const logger = new gdjs.Logger('Multiplayer');
type LobbyChangeHostRequest = {
lobbyId: string;
gameId: string;
peerId: string;
playerId: string;
ping: number;
createdAt: number;
ttl: number;
newLobbyId?: string;
newHostPeerId?: string;
newPlayers?: {
playerNumber: number;
playerId: string;
}[];
};
type LobbyStatus =
| 'waiting'
| 'starting'
| 'playing'
| 'migrating'
| 'migrated';
type LobbyConnectionStatus = 'waiting' | 'ready' | 'connected';
type InGamePlayerStatus = 'playing' | 'left';
type PlayerStatus = LobbyConnectionStatus | InGamePlayerStatus;
type LobbyPlayer = {
playerId: string;
status: PlayerStatus;
playerNumber: number;
};
type Lobby = {
id: string;
minPlayers: number;
maxPlayers: number;
canJoinAfterStart: boolean;
players: LobbyPlayer[];
status: LobbyStatus;
};
type QuickJoinLobbyResponse =
| { status: 'join-game'; lobby: Lobby }
| { status: 'join-lobby'; lobby: Lobby }
| { status: 'not-enough-players' }
| { status: 'full' };
const getTimeNow =
window.performance && typeof window.performance.now === 'function'
? window.performance.now.bind(window.performance)
: Date.now;
const fetchAsPlayer = async ({
relativeUrl,
method,
body,
dev,
}: {
relativeUrl: string;
method: 'GET' | 'POST';
body?: string;
dev: boolean;
}) => {
const playerId = gdjs.playerAuthentication.getUserId();
const playerToken = gdjs.playerAuthentication.getUserToken();
if (!playerId || !playerToken) {
logger.warn('Cannot fetch as a player if the player is not connected.');
throw new Error(
'Cannot fetch as a player if the player is not connected.'
);
}
const rootApi = dev
? 'https://api-dev.gdevelop.io'
: 'https://api.gdevelop.io';
const url = new URL(`${rootApi}${relativeUrl}`);
url.searchParams.set('playerId', playerId);
const formattedUrl = url.toString();
const headers = {
'Content-Type': 'application/json',
Authorization: `player-game-token ${playerToken}`,
};
const response = await fetch(formattedUrl, {
method,
headers,
body,
});
if (!response.ok) {
throw new Error(
`Error while fetching as a player: ${response.status} ${response.statusText}`
);
}
// Response can either be 'OK' or a JSON object. Get the content before trying to parse it.
const responseText = await response.text();
if (responseText === 'OK') {
return;
}
try {
return JSON.parse(responseText);
} catch (error) {
throw new Error(`Error while parsing the response: ${error}`);
}
};
export namespace multiplayer {
/** Set to true in testing to avoid relying on the multiplayer extension. */
export let disableMultiplayerForTesting = false;
export let _isReadyToSendOrReceiveGameUpdateMessages = false;
let _isGameRegistered: boolean | null = null;
let _isCheckingIfGameIsRegistered = false;
let _isWaitingForLogin = false;
let _hasLobbyGameJustStarted = false;
export let _isLobbyGameRunning = false;
let _hasLobbyGameJustEnded = false;
let _quickJoinLobbyJustFailed = false;
let _quickJoinLobbyFailureReason:
| 'FULL'
| 'NOT_ENOUGH_PLAYERS'
| 'DOES_NOT_EXIST'
| 'UNKNOWN'
| null = null;
let _lobbyId: string | null = null;
let _connectionId: string | null = null;
let _shouldEndLobbyWhenHostLeaves = false;
let _lobbyChangeHostRequest: LobbyChangeHostRequest | null = null;
let _lobbyChangeHostRequestInitiatedAt: number | null = null;
let _isChangingHost = false;
let _lobbyNewHostPickedAt: number | null = null;
let _actionAfterJoiningLobby:
| 'OPEN_LOBBY_PAGE'
| 'JOIN_GAME'
| 'START_GAME'
| null = null;
let _isQuickJoiningOrStartingAGame = false;
let _lastQuickJoinRequestDoneAt: number | null = null;
// Communication methods.
let _lobbiesMessageCallback: ((event: MessageEvent) => void) | null = null;
let _websocket: WebSocket | null = null;
let _websocketHeartbeatIntervalFunction: NodeJS.Timeout | null = null;
let _lobbyHeartbeatIntervalFunction: NodeJS.Timeout | null = null;
const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL = 10000;
const DEFAULT_LOBBY_HEARTBEAT_INTERVAL = 30000;
let currentLobbyHeartbeatInterval = DEFAULT_LOBBY_HEARTBEAT_INTERVAL;
const DEFAULT_LOBBY_CHANGE_HOST_REQUEST_CHECK_INTERVAL = 1000;
// 10 seconds to be safe, but the backend will answer in less.
const DEFAULT_LOBBY_CHANGE_HOST_REQUEST_TIMEOUT = 10000;
const DEFAULT_LOBBY_EXPECTED_CONNECTED_PLAYERS_CHECK_INTERVAL = 1000;
const DEFAULT_LOBBY_EXPECTED_CONNECTED_PLAYERS_TIMEOUT = 10000;
let _resumeTimeout: NodeJS.Timeout | null = null;
const DEFAULT_LOBBY_EXPECTED_RESUME_TIMEOUT = 12000;
export const DEFAULT_OBJECT_MAX_SYNC_RATE = 30;
// The number of times per second an object should be synchronized if it keeps changing.
export let _objectMaxSyncRate = DEFAULT_OBJECT_MAX_SYNC_RATE;
// Save if we are on dev environment so we don't need to use the runtimeGame every time.
let isUsingGDevelopDevelopmentEnvironment = false;
export let playerNumber: number | null = null;
export let hostPeerId: string | null = null;
gdjs.registerRuntimeScenePreEventsCallback(
(runtimeScene: gdjs.RuntimeScene) => {
isUsingGDevelopDevelopmentEnvironment = runtimeScene
.getGame()
.isUsingGDevelopDevelopmentEnvironment();
if (disableMultiplayerForTesting) return;
gdjs.multiplayerMessageManager.handleHeartbeatsToSend();
gdjs.multiplayerMessageManager.handleJustDisconnectedPeers(
runtimeScene
);
gdjs.multiplayerMessageManager.handleChangeInstanceOwnerMessagesReceived(
runtimeScene
);
gdjs.multiplayerMessageManager.handleUpdateInstanceMessagesReceived(
runtimeScene
);
gdjs.multiplayerMessageManager.handleCustomMessagesReceived();
gdjs.multiplayerMessageManager.handleAcknowledgeMessagesReceived();
gdjs.multiplayerMessageManager.resendClearOrCancelAcknowledgedMessages(
runtimeScene
);
gdjs.multiplayerMessageManager.handleChangeVariableOwnerMessagesReceived(
runtimeScene
);
// In case we're joining an existing lobby, it's possible we haven't
// fully caught up with the game state yet, especially if a scene is loading.
// We look at them every frame, from the moment the lobby has started,
// to ensure we don't miss any.
if (_isLobbyGameRunning) {
gdjs.multiplayerMessageManager.handleSavedUpdateMessages(
runtimeScene
);
}
gdjs.multiplayerMessageManager.handleUpdateGameMessagesReceived(
runtimeScene
);
gdjs.multiplayerMessageManager.handleUpdateSceneMessagesReceived(
runtimeScene
);
}
);
gdjs.registerRuntimeScenePostEventsCallback(
(runtimeScene: gdjs.RuntimeScene) => {
if (disableMultiplayerForTesting) return;
// Handle joining and leaving players to show notifications accordingly.
handleLeavingPlayer(runtimeScene);
handleJoiningPlayer(runtimeScene);
// Then look at the heartbeats received to know if a new player has joined/left.
gdjs.multiplayerMessageManager.handleHeartbeatsReceived();
gdjs.multiplayerMessageManager.handleEndGameMessagesReceived();
gdjs.multiplayerMessageManager.handleResumeGameMessagesReceived(
runtimeScene
);
gdjs.multiplayerMessageManager.handleDestroyInstanceMessagesReceived(
runtimeScene
);
gdjs.multiplayerVariablesManager.handleChangeVariableOwnerMessagesToSend();
gdjs.multiplayerMessageManager.handleUpdateGameMessagesToSend(
runtimeScene
);
gdjs.multiplayerMessageManager.handleUpdateSceneMessagesToSend(
runtimeScene
);
}
);
// Ensure that the condition "game just started" (or ended) is valid only for one frame.
gdjs.registerRuntimeScenePostEventsCallback(() => {
if (disableMultiplayerForTesting) return;
_hasLobbyGameJustStarted = false;
_hasLobbyGameJustEnded = false;
_quickJoinLobbyJustFailed = false;
});
const getLobbiesWindowUrl = ({
runtimeGame,
gameId,
}: {
runtimeGame: gdjs.RuntimeGame;
gameId: string;
}) => {
// Uncomment to test the case of a failing loading:
// return 'https://gd.games.wronglink';
const baseUrl = 'https://gd.games';
// Uncomment to test locally:
// const baseUrl = 'http://localhost:4000';
const url = new URL(
`${baseUrl}/games/${gameId}/lobbies${_lobbyId ? `/${_lobbyId}` : ''}`
);
url.searchParams.set(
'gameVersion',
runtimeGame.getGameData().properties.version
);
if (runtimeGame.getAdditionalOptions().nativeMobileApp) {
url.searchParams.set('nativeMobileApp', 'true');
}
url.searchParams.set(
'isPreview',
runtimeGame.isPreview() ? 'true' : 'false'
);
if (isUsingGDevelopDevelopmentEnvironment) {
url.searchParams.set('dev', 'true');
}
if (_connectionId) {
url.searchParams.set('connectionId', _connectionId);
}
if (playerNumber) {
url.searchParams.set('positionInLobby', playerNumber.toString());
}
const playerId = gdjs.playerAuthentication.getUserId();
if (playerId) {
url.searchParams.set('playerId', playerId);
}
const playerToken = gdjs.playerAuthentication.getUserToken();
if (playerToken) {
url.searchParams.set('playerToken', playerToken);
}
const platformInfo = runtimeGame.getPlatformInfo();
url.searchParams.set(
'scm',
platformInfo.supportedCompressionMethods.join(',')
);
// Increment this value when a new feature is introduced so we can
// adapt the interface of the lobbies.
url.searchParams.set('multiplayerVersion', '2');
return url.toString();
};
export const setObjectsSynchronizationRate = (rate: number) => {
if (rate < 1 || rate > 60) {
logger.warn(
`Invalid rate ${rate} for object synchronization. Defaulting to ${DEFAULT_OBJECT_MAX_SYNC_RATE}.`
);
_objectMaxSyncRate = DEFAULT_OBJECT_MAX_SYNC_RATE;
} else {
_objectMaxSyncRate = rate;
}
};
export const getObjectsSynchronizationRate = () => _objectMaxSyncRate;
/**
* Returns true if the game has just started,
* useful to switch to the game scene.
*/
export const hasLobbyGameJustStarted = () => _hasLobbyGameJustStarted;
export const isLobbyGameRunning = () => _isLobbyGameRunning;
export const isReadyToSendOrReceiveGameUpdateMessages = () =>
_isReadyToSendOrReceiveGameUpdateMessages;
/**
* Returns true if the game has just ended,
* useful to switch back to to the main menu.
*/
export const hasLobbyGameJustEnded = () => _hasLobbyGameJustEnded;
/**
* Returns the number of players in the lobby.
*/
export const getPlayersInLobbyCount = (): number => {
// Whether the lobby game has started or not, the number of players in the lobby
// is the number of connected players.
return gdjs.multiplayerMessageManager.getNumberOfConnectedPlayers();
};
/**
* Returns true if the player at this position is connected to the lobby.
*/
export const isPlayerConnected = (playerNumber: number): boolean => {
return gdjs.multiplayerMessageManager.isPlayerConnected(playerNumber);
};
/**
* Returns the position of the current player in the lobby.
* Return 0 if the player is not in the lobby.
* Returns 1, 2, 3, ... if the player is in the lobby.
*/
export const getCurrentPlayerNumber = (): number => {
return playerNumber || 0;
};
/**
* Returns true if the player is the host in the lobby.
* This can change during the game.
*/
export const isCurrentPlayerHost = (): boolean => {
return (
!!hostPeerId &&
hostPeerId === gdjs.multiplayerPeerJsHelper.getCurrentId()
);
};
/**
* Returns true if the host left and the game is either:
* - picking a new host
* - waiting for everyone to connect to the new host
*/
export const isMigratingHost = (): boolean => {
return !!_isChangingHost;
};
/**
* If this is set, instead of migrating the host, the lobby will end when the host leaves.
*/
export const endLobbyWhenHostLeaves = (enable: boolean) => {
_shouldEndLobbyWhenHostLeaves = enable;
};
export const shouldEndLobbyWhenHostLeaves = () =>
_shouldEndLobbyWhenHostLeaves;
/**
* Returns the player username at the given number in the lobby.
* The number is shifted by one, so that the first player has number 1.
*/
export const getPlayerUsername = (playerNumber: number): string => {
return gdjs.multiplayerMessageManager.getPlayerUsername(playerNumber);
};
/**
* Returns the player username of the current player in the lobby.
*/
export const getCurrentPlayerUsername = (): string => {
const currentPlayerNumber = getCurrentPlayerNumber();
return getPlayerUsername(currentPlayerNumber);
};
const handleLeavingPlayer = (runtimeScene: gdjs.RuntimeScene) => {
const lastestPlayerWhoJustLeft =
gdjs.multiplayerMessageManager.getLatestPlayerWhoJustLeft();
if (lastestPlayerWhoJustLeft) {
const playerUsername = getPlayerUsername(lastestPlayerWhoJustLeft);
gdjs.multiplayerComponents.displayPlayerLeftNotification(
runtimeScene,
playerUsername
);
// We remove the players who just left 1 by 1, so that they can be treated in different frames.
// This is especially important if the expression to know the latest player who just left is used,
// to avoid missing a player leaving.
gdjs.multiplayerMessageManager.removePlayerWhoJustLeft();
// When a player leaves, we send a heartbeat to the backend so that they're aware of the players in the lobby.
// Do not await as we want don't want to block the execution of the of the rest of the logic.
if (
isCurrentPlayerHost() &&
isReadyToSendOrReceiveGameUpdateMessages()
) {
sendHeartbeatToBackend();
}
}
};
const handleJoiningPlayer = (runtimeScene: gdjs.RuntimeScene) => {
const lastestPlayerWhoJustJoined =
gdjs.multiplayerMessageManager.getLatestPlayerWhoJustJoined();
if (lastestPlayerWhoJustJoined) {
const playerUsername = getPlayerUsername(lastestPlayerWhoJustJoined);
gdjs.multiplayerComponents.displayPlayerJoinedNotification(
runtimeScene,
playerUsername
);
// We also send a heartbeat to the backend right away, so that they're aware of the players in the lobby.
// Do not await as we want don't want to block the execution of the of the rest of the logic.
if (
isCurrentPlayerHost() &&
isReadyToSendOrReceiveGameUpdateMessages()
) {
sendHeartbeatToBackend();
}
}
// We remove the players who just joined 1 by 1, so that they can be treated in different frames.
// This is especially important if the expression to know the latest player who just joined is used,
// to avoid missing a player joining.
gdjs.multiplayerMessageManager.removePlayerWhoJustJoined();
};
/**
* Returns true if the game is registered, false otherwise.
* Useful to display a message to the user to register the game before logging in.
*/
const checkIfGameIsRegistered = (
runtimeGame: gdjs.RuntimeGame,
gameId: string,
tries: number = 0
): Promise<boolean> => {
const rootApi = isUsingGDevelopDevelopmentEnvironment
? 'https://api-dev.gdevelop.io'
: 'https://api.gdevelop.io';
const url = `${rootApi}/game/public-game/${gameId}`;
return fetch(url, { method: 'HEAD' }).then(
(response) => {
if (response.status !== 200) {
logger.warn(
`Error while fetching the game: ${response.status} ${response.statusText}`
);
// If the response is not 404, it may be a timeout, so retry a few times.
if (response.status === 404 || tries > 2) {
return false;
}
return checkIfGameIsRegistered(runtimeGame, gameId, tries + 1);
}
return true;
},
(err) => {
logger.error('Error while fetching game:', err);
return false;
}
);
};
const handleJoinLobbyEvent = function (
runtimeScene: gdjs.RuntimeScene,
lobbyId: string
) {
if (_connectionId) {
logger.info('Already connected to a lobby.');
return;
}
if (_websocket) {
logger.warn('Already connected to a lobby. Closing the previous one.');
_websocket.close();
_connectionId = null;
playerNumber = null;
hostPeerId = null;
_lobbyId = null;
_websocket = null;
}
const gameId = gdjs.projectData.properties.projectUuid;
const playerId = gdjs.playerAuthentication.getUserId();
const playerToken = gdjs.playerAuthentication.getUserToken();
if (!gameId) {
logger.error('Cannot open lobbies if the project has no ID.');
return;
}
if (!playerId || !playerToken) {
logger.warn('Cannot open lobbies if the player is not connected.');
return;
}
const wsPlayApi = isUsingGDevelopDevelopmentEnvironment
? 'wss://api-ws-dev.gdevelop.io/play'
: 'wss://api-ws.gdevelop.io/play';
const wsUrl = new URL(wsPlayApi);
wsUrl.searchParams.set('gameId', gameId);
wsUrl.searchParams.set('lobbyId', lobbyId);
wsUrl.searchParams.set('playerId', playerId);
wsUrl.searchParams.set('connectionType', 'lobby');
wsUrl.searchParams.set('playerGameToken', playerToken);
_websocket = new WebSocket(wsUrl.toString());
_websocket.onopen = () => {
logger.info('Connected to the lobby.');
// Register a heartbeat to keep the connection alive.
_websocketHeartbeatIntervalFunction = setInterval(() => {
if (_websocket) {
_websocket.send(
JSON.stringify({
action: 'heartbeat',
connectionType: 'lobby',
})
);
}
}, DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL);
// When socket is open, ask for the connectionId and send more session info, so that we can inform the lobbies window.
if (_websocket) {
_websocket.send(JSON.stringify({ action: 'getConnectionId' }));
const platformInfo = runtimeScene.getGame().getPlatformInfo();
_websocket.send(
JSON.stringify({
action: 'sessionInformation',
connectionType: 'lobby',
isCordova: platformInfo.isCordova,
devicePlatform: platformInfo.devicePlatform,
navigatorPlatform: platformInfo.navigatorPlatform,
hasTouch: platformInfo.hasTouch,
supportedCompressionMethods:
platformInfo.supportedCompressionMethods,
})
);
}
};
_websocket.onmessage = (event) => {
if (event.data) {
const messageContent = JSON.parse(event.data);
switch (messageContent.type) {
case 'connectionId': {
const messageData = messageContent.data;
const connectionId = messageData.connectionId;
const positionInLobby = messageData.positionInLobby;
const validIceServers = messageData.validIceServers || [];
const brokerServerConfig = messageData.brokerServerConfig;
if (!connectionId || !positionInLobby) {
logger.error('No connectionId or position received');
gdjs.multiplayerComponents.displayErrorNotification(
runtimeScene
);
// Close the websocket as something wrong happened.
if (_websocket) _websocket.close();
return;
}
handleConnectionIdReceived({
runtimeScene,
connectionId,
positionInLobby,
lobbyId,
playerId,
playerToken,
validIceServers,
brokerServerConfig,
});
break;
}
case 'lobbyUpdated': {
const messageData = messageContent.data;
const positionInLobby = messageData.positionInLobby;
handleLobbyUpdatedEvent({
runtimeScene,
positionInLobby,
});
break;
}
case 'gameCountdownStarted': {
const messageData = messageContent.data;
const compressionMethod = messageData.compressionMethod || 'none';
handleGameCountdownStartedEvent({
runtimeScene,
compressionMethod,
});
break;
}
case 'gameStarted': {
const messageData = messageContent.data;
currentLobbyHeartbeatInterval =
messageData.heartbeatInterval ||
DEFAULT_LOBBY_HEARTBEAT_INTERVAL;
handleGameStartedEvent({
runtimeScene,
});
break;
}
case 'peerId': {
const messageData = messageContent.data;
if (!messageData) {
logger.error('No message received');
return;
}
const peerId = messageData.peerId;
const compressionMethod = messageData.compressionMethod;
if (!peerId || !compressionMethod) {
logger.error('Malformed message received');
return;
}
const retryData = { times: 2, delayInMs: 500 };
try {
gdjs.evtTools.network.retryIfFailed(retryData, async () => {
handlePeerIdEvent({ peerId, compressionMethod });
});
} catch (error) {
logger.error(
`Handling peerId message from websocket failed (after {${retryData.times}} times with a delay of ${retryData.delayInMs}ms). Not trying anymore.`
);
}
break;
}
}
}
};
_websocket.onclose = () => {
if (!_isLobbyGameRunning) {
logger.info('Disconnected from the lobby.');
}
_connectionId = null;
_websocket = null;
if (_websocketHeartbeatIntervalFunction) {
clearInterval(_websocketHeartbeatIntervalFunction);
}
// If the game is running, then all good.
// Otherwise, the player left the lobby.
if (_isLobbyGameRunning) {
return;
}
const lobbiesIframe =
gdjs.multiplayerComponents.getLobbiesIframe(runtimeScene);
if (!lobbiesIframe || !lobbiesIframe.contentWindow) {
return;
}
// Tell the Lobbies iframe that the lobby has been left.
lobbiesIframe.contentWindow.postMessage(
{
id: 'lobbyLeft',
},
'*' // We could restrict to GDevelop games platform but it's not necessary as the message is not sensitive, and it allows easy debugging.
);
};
};
const onPeerUnavailable = (runtimeScene: gdjs.RuntimeScene) => {
gdjs.multiplayerComponents.displayConnectionErrorNotification(
runtimeScene
);
handleLeaveLobbyEvent();
_actionAfterJoiningLobby = null;
_quickJoinLobbyFailureReason = null;
if (_isQuickJoiningOrStartingAGame)
onLobbyQuickJoinFinished(runtimeScene);
};
const handleConnectionIdReceived = function ({
runtimeScene,
connectionId,
positionInLobby,
lobbyId,
playerId,
playerToken,
validIceServers,
brokerServerConfig,
}: {
runtimeScene: gdjs.RuntimeScene;
connectionId: string;
positionInLobby: number;
lobbyId: string;
playerId: string;
playerToken: string;
validIceServers: {
urls: string;
username?: string;
credential?: string;
}[];
brokerServerConfig?: {
hostname: string;
port: number;
path: string;
key: string;
secure: boolean;
};
}) {
// When the connectionId is received, initialise PeerJS so players can connect to each others afterwards.
if (validIceServers.length) {
for (const server of validIceServers) {
gdjs.multiplayerPeerJsHelper.useCustomICECandidate(
server.urls,
server.username,
server.credential
);
}
}
if (brokerServerConfig) {
gdjs.multiplayerPeerJsHelper.useCustomBrokerServer(
brokerServerConfig.hostname,
brokerServerConfig.port,
brokerServerConfig.path,
brokerServerConfig.key,
brokerServerConfig.secure,
{ onPeerUnavailable: () => onPeerUnavailable(runtimeScene) }
);
} else {
gdjs.multiplayerPeerJsHelper.useDefaultBrokerServer({
onPeerUnavailable: () => onPeerUnavailable(runtimeScene),
});
}
_connectionId = connectionId;
playerNumber = positionInLobby;
// We save the lobbyId here as this is the moment when the player is really connected to the lobby.
_lobbyId = lobbyId;
if (_actionAfterJoiningLobby === 'OPEN_LOBBY_PAGE') {
openLobbiesWindow(runtimeScene);
onLobbyQuickJoinFinished(runtimeScene);
return;
} else if (_actionAfterJoiningLobby === 'JOIN_GAME') {
handleJoinGameMessage();
return;
} else if (_actionAfterJoiningLobby === 'START_GAME') {
const retryData = { times: 2, delayInMs: 500 };
try {
gdjs.evtTools.network.retryIfFailed(retryData, async () => {
sendPeerId();
handleStartGameMessage();
});
} catch (error) {
logger.error(
`Sending of peerId message from websocket failed (after {${retryData.times}} times with a delay of ${retryData.delayInMs}ms). Not trying anymore.`
);
}
return;
}
// Then we inform the lobbies window that the player has joined.
const lobbiesIframe =
gdjs.multiplayerComponents.getLobbiesIframe(runtimeScene);
if (!lobbiesIframe || !lobbiesIframe.contentWindow) {
logger.error(
'The lobbies iframe is not opened, cannot send the join message.'
);
return;
}
lobbiesIframe.contentWindow.postMessage(
{
id: 'lobbyJoined',
lobbyId,
playerId,
playerToken,
connectionId: _connectionId,
positionInLobby,
},
// Specify the origin to avoid leaking the playerToken.
// Replace with '*' to test locally.
'https://gd.games'
// '*'
);
};
const handleLeaveLobbyEvent = function () {
if (_websocket) {
_websocket.close();
}
_connectionId = null;
playerNumber = null;
hostPeerId = null;
_lobbyId = null;
_websocket = null;
};
const handleLobbyUpdatedEvent = function ({
runtimeScene,
positionInLobby,
}: {
runtimeScene: gdjs.RuntimeScene;
positionInLobby: number;
}) {
// This is mainly useful when joining a lobby, or when the lobby is updated before the game starts.
// The position in lobby should never change after the game has started (the WS is closed anyway).
playerNumber = positionInLobby;
// If the player is in the lobby, tell the lobbies window that the lobby has been updated,
// as well as the player position.
const lobbiesIframe =
gdjs.multiplayerComponents.getLobbiesIframe(runtimeScene);
if (!lobbiesIframe || !lobbiesIframe.contentWindow) {
return;
}
lobbiesIframe.contentWindow.postMessage(
{
id: 'lobbyUpdated',
positionInLobby,
},
'*' // We could restrict to GDevelop games platform but it's not necessary as the message is not sensitive, and it allows easy debugging.
);
};
const handleGameCountdownStartedEvent = function ({
runtimeScene,
compressionMethod,
}: {
runtimeScene: gdjs.RuntimeScene;
compressionMethod: gdjs.multiplayerPeerJsHelper.CompressionMethod;
}) {
gdjs.multiplayerPeerJsHelper.setCompressionMethod(compressionMethod);
// When the countdown starts, if we are player number 1, we are chosen as the host.
// We then send the peerId to others so they can connect via P2P.
// TODO: this should be sent by the backend, in case the lobby starts without a player 1.
if (getCurrentPlayerNumber() === 1) {
sendPeerId();
}
// Just pass along the message to the iframe so that it can display the countdown.
const lobbiesIframe =
gdjs.multiplayerComponents.getLobbiesIframe(runtimeScene);
if (!lobbiesIframe || !lobbiesIframe.contentWindow) {
logger.info('The lobbies iframe is not opened, not sending message.');
return;
}
lobbiesIframe.contentWindow.postMessage(
{
id: 'gameCountdownStarted',
},
'*' // We could restrict to GDevelop games platform but it's not necessary as the message is not sensitive, and it allows easy debugging.
);
// Prevent the player from leaving the lobby while the game is starting.
gdjs.multiplayerComponents.hideLobbiesCloseButtonTemporarily(
runtimeScene
);
};
const sendHeartbeatToBackend = async function () {
const gameId = gdjs.projectData.properties.projectUuid;
if (!gameId || !_lobbyId) {
logger.error(
'Cannot keep the lobby playing without the game ID or lobby ID.'
);
return;
}
const heartbeatRelativeUrl = `/play/game/${gameId}/public-lobby/${_lobbyId}/action/heartbeat`;
const players = gdjs.multiplayerMessageManager.getConnectedPlayers();
try {
await fetchAsPlayer({
relativeUrl: heartbeatRelativeUrl,
method: 'POST',
body: JSON.stringify({
players,
}),
dev: isUsingGDevelopDevelopmentEnvironment,
});
// TODO: if 404, there's chance that it means the lobby is now closed. Display a message
// to the player?
} catch (error) {
logger.error('Error while sending heartbeat, retrying:', error);
try {
await fetchAsPlayer({
relativeUrl: heartbeatRelativeUrl,
method: 'POST',
body: JSON.stringify({
players,
}),
dev: isUsingGDevelopDevelopmentEnvironment,
});
} catch (error) {
logger.error(
'Error while sending heartbeat a second time. Giving up:',
error
);
}
}
};
/**
* When the game receives the information that the game has started, close the
* lobbies window, focus on the game, and set the flag to true.
*/
const handleGameStartedEvent = function ({
runtimeScene,
}: {
runtimeScene: gdjs.RuntimeScene;
}) {
// It is possible the connection to other players didn't work.
// If that's the case, show an error message and leave the lobby.
// If we are the host, still start the game, as this allows a player to test the game alone.
const allConnectedPeers = gdjs.multiplayerPeerJsHelper.getAllPeers();
if (!isCurrentPlayerHost() && allConnectedPeers.length === 0) {
gdjs.multiplayerComponents.displayConnectionErrorNotification(
runtimeScene
);
// Do as if the player left the lobby.
handleLeaveLobbyEvent();
removeLobbiesContainer(runtimeScene);
focusOnGame(runtimeScene);
return;
}
// If we are the host, start pinging the backend to let it know the lobby is running.
if (isCurrentPlayerHost()) {
_lobbyHeartbeatIntervalFunction = setInterval(async () => {
await sendHeartbeatToBackend();
}, currentLobbyHeartbeatInterval);
}
// If we are connected to players, then the game can start.
logger.info('Lobby game has started.');
// In case we're joining an existing lobby, read the saved messages to catch-up with the game state.
gdjs.multiplayerMessageManager.handleSavedUpdateMessages(runtimeScene);
if (_isQuickJoiningOrStartingAGame)
onLobbyQuickJoinFinished(runtimeScene);
_isReadyToSendOrReceiveGameUpdateMessages = true;
_hasLobbyGameJustStarted = true;
_isLobbyGameRunning = true;
removeLobbiesContainer(runtimeScene);
// Close the websocket, as we don't need it anymore.
if (_websocket) {
_websocket.close();
}
focusOnGame(runtimeScene);
};
/**
* When the game receives the information that the game has ended, set the flag to true,
* so that the game can switch back to the main menu for instance.
*/
export const handleLobbyGameEnded = function () {
logger.info('Lobby game has ended.');
_hasLobbyGameJustEnded = true;
_isLobbyGameRunning = false;
_lobbyId = null;
playerNumber = null;
hostPeerId = null;
_isReadyToSendOrReceiveGameUpdateMessages = false;
if (_lobbyHeartbeatIntervalFunction) {
clearInterval(_lobbyHeartbeatIntervalFunction);
_lobbyHeartbeatIntervalFunction = null;
}
// Disconnect from any P2P connections.
gdjs.multiplayerPeerJsHelper.disconnectFromAllPeers();
// Clear the expected acknowledgments, as the game is ending.
gdjs.multiplayerMessageManager.clearAllMessagesTempData();
};
/**
* When the game receives the information of the peerId, then
* the player can connect to the peer.
*/
const handlePeerIdEvent = function ({
peerId,
compressionMethod,
}: {
peerId: string;
compressionMethod: gdjs.multiplayerPeerJsHelper.CompressionMethod;
}) {
// When a peerId is received, trigger a P2P connection with the peer, just after setting the compression method.
gdjs.multiplayerPeerJsHelper.setCompressionMethod(compressionMethod);
const currentPeerId = gdjs.multiplayerPeerJsHelper.getCurrentId();
if (!currentPeerId) {
logger.error(
'No peerId found, the player does not seem connected to the broker server.'
);
throw new Error('Missing player peerId.');
}
if (currentPeerId === peerId) {
logger.info('Received our own peerId, ignoring.');
return;
}
hostPeerId = peerId;
gdjs.multiplayerPeerJsHelper.connect(peerId);
};
/**
* When the game receives a start countdown message from the lobby, just send it to all
* players in the lobby via the websocket.
* It will then trigger an event from the websocket to all players in the lobby.
*/
const handleStartGameCountdownMessage = function () {
if (!_websocket) {
logger.error(
'No connection to send the start countdown message. Are you connected to a lobby?'
);
return;
}
_websocket.send(
JSON.stringify({
action: 'startGameCountdown',
connectionType: 'lobby',
})
);
};
/**
* When the game receives a start game message from the lobby, just send it to all
* players in the lobby via the websocket.
* It will then trigger an event from the websocket to all players in the lobby.
*/
const handleStartGameMessage = function () {
if (!_websocket) {
logger.error(
'No connection to send the start countdown message. Are you connected to a lobby?'
);
return;
}
_websocket.send(
JSON.stringify({
action: 'startGame',
connectionType: 'lobby',
})
);
// As the host, start sending messages to the players.
_isReadyToSendOrReceiveGameUpdateMessages = true;
};
/**
* When the game receives a join game message from the lobby, send it via the WS
* waiting for a peerId to be received and that the connection happens automatically.
*/
const handleJoinGameMessage = function () {
if (!_websocket) {
logger.error(
'No connection to send the join game message. Are you connected to a lobby?'
);
return;
}
// TODO: When the message is sent, it is expected to then receive a "peerId" message
// from the websocket. This "peerId" message might not be sent for different reasons.
// Should there be a security that checks if the "peerId" message has been received
// in the next 10s or something more global that checks the lobby status after the player
// has committed to open a connection with it?
_websocket.send(
JSON.stringify({
action: 'joinGame',
connectionType: 'lobby',
})
);
};
/**
* When the first heartbeat is received, we consider the connection to the host as working,
* we inform the backend services that the connection is ready, so it can start the game when
* everyone is ready.
*/
export const markConnectionAsConnected = function () {
if (!_websocket) {
return;
}
_websocket.send(
JSON.stringify({
action: 'updateConnection',
connectionType: 'lobby',
status: 'connected',
peerId: gdjs.multiplayerPeerJsHelper.getCurrentId(),
})
);
};
const clearChangeHostRequestData = function (
runtimeScene: gdjs.RuntimeScene
) {
_lobbyChangeHostRequest = null;
_lobbyChangeHostRequestInitiatedAt = null;
_lobbyNewHostPickedAt = null;
if (_resumeTimeout) {
clearTimeout(_resumeTimeout);
_resumeTimeout = null;
}
_isChangingHost = false;
if (hostPeerId) {
gdjs.multiplayerComponents.showHostMigrationFinishedNotification(
runtimeScene
);
} else {
gdjs.multiplayerComponents.showHostMigrationFailedNotification(
runtimeScene
);
}
};
export const resumeGame = async function (runtimeScene: gdjs.RuntimeScene) {
if (isCurrentPlayerHost()) {
// Send message to other players to indicate the game is resuming.
gdjs.multiplayerMessageManager.sendResumeGameMessage();
// Start sending heartbeats to the backend.
await sendHeartbeatToBackend();
_lobbyHeartbeatIntervalFunction = setInterval(async () => {
await sendHeartbeatToBackend();
}, currentLobbyHeartbeatInterval);
}
// Migration is finished.
clearChangeHostRequestData(runtimeScene);
};
/**
* When a host is being changed, multiple cases can happen:
* - We are the new host and the only one in the lobby. Unpause the game right away.
* - We are the new host and there are other players in the new lobby. Wait for them to connect:
* - if they are all connected, unpause the game.
* - if we reach a timeout, a player may have disconnected at the same time, unpause the game.
* - We are not the new host. Connect to the new host peerId.
* - If we cannot connect, leave the lobby.
* - when we receive a message to unpause the game, unpause it.
* - if we reach a timeout without the message, leave the lobby, something wrong happened.
*/
const checkHostChangeRequestRegularly = async function ({
runtimeScene,
}: {
runtimeScene: gdjs.RuntimeScene;
}) {
if (!_lobbyChangeHostRequest || !_lobbyChangeHostRequestInitiatedAt) {
return;
}
// Refresh the request to get the latest information.
try {
const changeHostRelativeUrl = `/play/game/${
_lobbyChangeHostRequest.gameId
}/public-lobby/${
_lobbyChangeHostRequest.lobbyId
}/lobby-change-host-request?peerId=${gdjs.multiplayerPeerJsHelper.getCurrentId()}`;
const lobbyChangeHostRequest = await fetchAsPlayer({
relativeUrl: changeHostRelativeUrl,
method: 'GET',
dev: isUsingGDevelopDevelopmentEnvironment,
});
_lobbyChangeHostRequest = lobbyChangeHostRequest;
} catch (error) {
logger.error(
'Error while trying to retrieve the lobby change host request:',
error
);
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
return;
}
if (!_lobbyChangeHostRequest) {
throw new Error('No lobby change host request received.');
}
const newHostPeerId = _lobbyChangeHostRequest.newHostPeerId;
if (!newHostPeerId) {
logger.info('No new host picked yet.');
if (
getTimeNow() - _lobbyChangeHostRequestInitiatedAt >
DEFAULT_LOBBY_CHANGE_HOST_REQUEST_TIMEOUT
) {
logger.error(
'Timeout while waiting for the lobby host change. Giving up.'
);
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
return;
}
logger.info('Retrying...');
setTimeout(() => {
checkHostChangeRequestRegularly({ runtimeScene });
}, DEFAULT_LOBBY_CHANGE_HOST_REQUEST_CHECK_INTERVAL);
return;
}
try {
const newLobbyId = _lobbyChangeHostRequest.newLobbyId;
const newPlayers = _lobbyChangeHostRequest.newPlayers;
if (!newLobbyId || !newPlayers) {
logger.error(
'Change host request is incomplete. Cannot change host.'
);
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
return;
}
hostPeerId = newHostPeerId;
_lobbyNewHostPickedAt = getTimeNow();
_lobbyId = newLobbyId;
if (newHostPeerId === gdjs.multiplayerPeerJsHelper.getCurrentId()) {
logger.info(
`We are the new host. Switching to lobby ${newLobbyId} and awaiting for ${
newPlayers.length - 1
} player(s) to connect.`
);
await checkExpectedConnectedPlayersRegularly({
runtimeScene,
});
} else {
logger.info(
`Connecting to new host and switching lobby to ${newLobbyId}.`
);
gdjs.multiplayerPeerJsHelper.connect(newHostPeerId);
_resumeTimeout = setTimeout(() => {
logger.error(
'Timeout while waiting for the game to resume. Leaving the lobby.'
);
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
}, DEFAULT_LOBBY_EXPECTED_RESUME_TIMEOUT);
}
} catch (error) {
logger.error('Error while trying to change host:', error);
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
}
};
/**
* Helper for the new host, to check if they have all the expected players connected.
*/
const checkExpectedConnectedPlayersRegularly = async function ({
runtimeScene,
}: {
runtimeScene: gdjs.RuntimeScene;
}) {
if (!_lobbyChangeHostRequest) {
return;
}
const expectedNewPlayers = _lobbyChangeHostRequest.newPlayers;
if (!expectedNewPlayers) {
logger.error('No expected players in the lobby change host request.');
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
return;
}
const expectedNewOtherPlayerNumbers = expectedNewPlayers.map(
(player) => player.playerNumber
);
// First look for players who left during the migration.
const playerNumbersConnectedBeforeMigration =
gdjs.multiplayerMessageManager
.getConnectedPlayers()
.map((player) => player.playerNumber);
const playerNumbersWhoLeftDuringMigration =
playerNumbersConnectedBeforeMigration.filter(
(playerNumberBeforeMigration) =>
!expectedNewOtherPlayerNumbers.includes(playerNumberBeforeMigration)
);
playerNumbersWhoLeftDuringMigration.map((playerNumberWhoLeft) => {
logger.info(
`Player ${playerNumberWhoLeft} left during the host migration. Marking as disconnected.`
);
gdjs.multiplayerMessageManager.markPlayerAsDisconnected({
runtimeScene,
playerNumber: playerNumberWhoLeft,
});
});
// Then check if all expected players are connected.
const playerNumbersWhoDidNotConnect =
expectedNewOtherPlayerNumbers.filter(
(otherPlayerNumber) =>
otherPlayerNumber !== playerNumber && // We don't look for ourselves
!gdjs.multiplayerMessageManager.hasReceivedHeartbeatFromPlayer(
otherPlayerNumber
)
);
if (playerNumbersWhoDidNotConnect.length === 0) {
logger.info('All expected players are connected. Resuming the game.');
await resumeGame(runtimeScene);
return;
}
if (
_lobbyNewHostPickedAt &&
getTimeNow() - _lobbyNewHostPickedAt >
DEFAULT_LOBBY_EXPECTED_CONNECTED_PLAYERS_TIMEOUT &&
playerNumbersWhoDidNotConnect.length > 0
) {
logger.error(
`Timeout while waiting for players ${playerNumbersWhoDidNotConnect.join(
', '
)} to connect. Assume they disconnected.`
);
playerNumbersWhoDidNotConnect.map((missingPlayerNumber) => {
gdjs.multiplayerMessageManager.markPlayerAsDisconnected({
runtimeScene,
playerNumber: missingPlayerNumber,
});
});
await resumeGame(runtimeScene);
return;
}
setTimeout(() => {
checkExpectedConnectedPlayersRegularly({
runtimeScene,
});
}, DEFAULT_LOBBY_EXPECTED_CONNECTED_PLAYERS_CHECK_INTERVAL);
};
/**
* When the host disconnects, we inform the backend we lost the connection and we need a new lobby/host.
*/
export const handleHostDisconnected = async function ({
runtimeScene,
}: {
runtimeScene: gdjs.RuntimeScene;
}) {
if (!_isLobbyGameRunning) {
// This can happen when the game ends. Nothing to do here.
return;
}
if (_lobbyChangeHostRequest) {
// The new host disconnected while we are already changing host.
// Let's end the lobby game to avoid weird situations.
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
}
const gameId = gdjs.projectData.properties.projectUuid;
if (!gameId || !_lobbyId) {
logger.error(
'Cannot ask for a host change without the game ID or lobby ID.'
);
return;
}
try {
_isChangingHost = true;
gdjs.multiplayerComponents.displayHostMigrationNotification(
runtimeScene
);
const changeHostRelativeUrl = `/play/game/${gameId}/public-lobby/${_lobbyId}/lobby-change-host-request`;
const playersInfo = gdjs.multiplayerMessageManager.getPlayersInfo();
const playersInfoForHostChange = Object.keys(playersInfo).map(
(playerNumber) => {
return {
playerNumber: parseInt(playerNumber, 10),
playerId: playersInfo[playerNumber].playerId,
ping: playersInfo[playerNumber].ping,
};
}
);
const body = JSON.stringify({
playersInfo: playersInfoForHostChange,
peerId: gdjs.multiplayerPeerJsHelper.getCurrentId(),
});
const lobbyChangeHostRequest = await fetchAsPlayer({
relativeUrl: changeHostRelativeUrl,
method: 'POST',
body,
dev: isUsingGDevelopDevelopmentEnvironment,
});
_lobbyChangeHostRequest = lobbyChangeHostRequest;
_lobbyChangeHostRequestInitiatedAt = getTimeNow();
await checkHostChangeRequestRegularly({ runtimeScene });
} catch (error) {
logger.error('Error while trying to change host:', error);
handleLobbyGameEnded();
clearChangeHostRequestData(runtimeScene);
}
};
/**
* Action to end the lobby game.
* This will update the lobby status and inform everyone in the lobby that the game has ended.
*/
export const endLobbyGame = async function () {
if (!isLobbyGameRunning()) {
return;
}
if (!isCurrentPlayerHost()) {
logger.error('Only the host can end the game.');
return;
}
// Consider the game is ended, so that we don't listen to other players disconnecting.
_isLobbyGameRunning = false;
logger.info('Ending the lobby game.');
// Inform the players that the game has ended.
gdjs.multiplayerMessageManager.sendEndGameMessage();
// Also call backend to end the game.
const gameId = gdjs.projectData.properties.projectUuid;
if (!gameId || !_lobbyId) {
logger.error('Cannot end the lobby without the game ID or lobby ID.');
return;
}
const endGameRelativeUrl = `/play/game/${gameId}/public-lobby/${_lobbyId}/action/end`;
try {
await fetchAsPlayer({
relativeUrl: endGameRelativeUrl,
method: 'POST',
body: JSON.stringify({}),
dev: isUsingGDevelopDevelopmentEnvironment,
});
} catch (error) {
logger.error('Error while ending the game:', error);
}
// Do as if everyone left the lobby.
handleLobbyGameEnded();
};
/**
* Helper to send the ID from PeerJS to the lobby players.
*/
const sendPeerId = function () {
if (!_websocket) {
logger.error(
'No connection to send the message. Are you connected to a lobby?'
);
return;
}
const peerId = gdjs.multiplayerPeerJsHelper.getCurrentId();
if (!peerId) {
logger.error(
"No peerId found, the player doesn't seem connected to the broker server."
);
throw new Error('Missing player peerId.');
}
_websocket.send(
JSON.stringify({
action: 'sendPeerId',
connectionType: 'lobby',
peerId,
})
);
// We are the host.
hostPeerId = peerId;
};
/**
* Reads the event sent by the lobbies window and
* react accordingly.
*/
const receiveLobbiesMessage = function (
runtimeScene: gdjs.RuntimeScene,
event: MessageEvent,
{ checkOrigin }: { checkOrigin: boolean }
) {
const allowedOrigins = ['https://gd.games', 'http://localhost:4000'];
// Check origin of message.
if (checkOrigin && !allowedOrigins.includes(event.origin)) {
// Wrong origin. Return silently.
return;
}
// Check that message is not malformed.
if (!event.data.id) {
throw new Error('Malformed message');
}
// Handle message.
switch (event.data.id) {
case 'lobbiesListenerReady': {
sendSessionInformation(runtimeScene);
break;
}
case 'joinLobby': {
if (!event.data.lobbyId) {
throw new Error('Malformed message.');
}
_actionAfterJoiningLobby = null;
handleJoinLobbyEvent(runtimeScene, event.data.lobbyId);
break;
}
case 'startGameCountdown': {
handleStartGameCountdownMessage();
break;
}
case 'startGame': {
handleStartGameMessage();
break;
}
case 'leaveLobby': {
handleLeaveLobbyEvent();
break;
}
case 'joinGame': {
handleJoinGameMessage();
break;
}
}
};
/**
* Handle any error that can occur as part of displaying the lobbies.
*/
const handleLobbiesError = function (
runtimeScene: gdjs.RuntimeScene,
message: string
) {
logger.error(message);
removeLobbiesContainer(runtimeScene);
focusOnGame(runtimeScene);
};
const sendSessionInformation = (runtimeScene: gdjs.RuntimeScene) => {
const lobbiesIframe =
gdjs.multiplayerComponents.getLobbiesIframe(runtimeScene);
if (!lobbiesIframe || !lobbiesIframe.contentWindow) {
// Cannot send the message if the iframe is not opened.
return;
}
const platformInfo = runtimeScene.getGame().getPlatformInfo();
lobbiesIframe.contentWindow.postMessage(
{
id: 'sessionInformation',
isCordova: platformInfo.isCordova,
devicePlatform: platformInfo.devicePlatform,
navigatorPlatform: platformInfo.navigatorPlatform,
hasTouch: platformInfo.hasTouch,
},
'*'
);
};
/**
* Helper to handle lobbies iframe.
* We open an iframe, and listen to messages posted back to the game window.
*/
const openLobbiesIframe = (
runtimeScene: gdjs.RuntimeScene,
gameId: string
) => {
const targetUrl = getLobbiesWindowUrl({
runtimeGame: runtimeScene.getGame(),
gameId,
});
// Listen to messages posted by the lobbies window, so that we can
// know when they join or leave a lobby.
_lobbiesMessageCallback = (event: MessageEvent) => {
receiveLobbiesMessage(runtimeScene, event, {
checkOrigin: true,
});
};
window.addEventListener('message', _lobbiesMessageCallback, true);
gdjs.multiplayerComponents.displayIframeInsideLobbiesContainer(
runtimeScene,
targetUrl
);
};
const onLobbyQuickJoinFinished = (runtimeScene: gdjs.RuntimeScene) => {
_isQuickJoiningOrStartingAGame = false;
_actionAfterJoiningLobby = null;
gdjs.multiplayerComponents.displayLoader(runtimeScene, false);
};
const quickJoinLobby = async (
runtimeScene: gdjs.RuntimeScene,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (_isQuickJoiningOrStartingAGame) return;
const _gameId = gdjs.projectData.properties.projectUuid;
if (!_gameId) {
handleLobbiesError(
runtimeScene,
'The game ID is missing, the quick join lobby action cannot continue.'
);
return;
}
_quickJoinLobbyFailureReason = null;
_isQuickJoiningOrStartingAGame = true;
if (displayLoader) {
gdjs.multiplayerComponents.displayLoader(runtimeScene, true);
}
const quickJoinLobbyRelativeUrl = `/play/game/${_gameId}/public-lobby/action/quick-join`;
const platformInfo = runtimeScene.getGame().getPlatformInfo();
try {
const quickJoinLobbyResponse: QuickJoinLobbyResponse =
await gdjs.evtTools.network.retryIfFailed({ times: 2 }, () =>
fetchAsPlayer({
relativeUrl: quickJoinLobbyRelativeUrl,
method: 'POST',
dev: isUsingGDevelopDevelopmentEnvironment,
body: JSON.stringify({
isPreview: runtimeScene.getGame().isPreview(),
gameVersion: runtimeScene.getGame().getGameData().properties
.version,
supportedCompressionMethods:
platformInfo.supportedCompressionMethods,
}),
})
);
if (
quickJoinLobbyResponse.status === 'full' ||
quickJoinLobbyResponse.status === 'not-enough-players'
) {
_quickJoinLobbyJustFailed = true;
_quickJoinLobbyFailureReason =
quickJoinLobbyResponse.status === 'full'
? 'FULL'
: 'NOT_ENOUGH_PLAYERS';
onLobbyQuickJoinFinished(runtimeScene);
if (openLobbiesPageIfFailure) {
openLobbiesWindow(runtimeScene);
}
return;
}
if (quickJoinLobbyResponse.status === 'join-game') {
if (quickJoinLobbyResponse.lobby.status === 'waiting') {
_actionAfterJoiningLobby = 'START_GAME';
} else if (quickJoinLobbyResponse.lobby.status === 'playing') {
_actionAfterJoiningLobby = 'JOIN_GAME';
} else {
throw new Error(
`Lobby in wrong status: ${quickJoinLobbyResponse.status}`
);
}
} else {
if (_connectionId) {
// Already connected to a lobby.
onLobbyQuickJoinFinished(runtimeScene);
openLobbiesWindow(runtimeScene);
return;
} else {
_actionAfterJoiningLobby = 'OPEN_LOBBY_PAGE';
}
}
handleJoinLobbyEvent(runtimeScene, quickJoinLobbyResponse.lobby.id);
} catch (error) {
logger.error('An error occurred while joining a lobby:', error);
_quickJoinLobbyJustFailed = true;
_quickJoinLobbyFailureReason = 'UNKNOWN';
onLobbyQuickJoinFinished(runtimeScene);
if (openLobbiesPageIfFailure) {
openLobbiesWindow(runtimeScene);
}
}
};
export const getLobbyID = (): string => {
return _lobbyId || '';
};
const quickJoinWithLobbyID = async (
runtimeScene: gdjs.RuntimeScene,
lobbyID: string,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (_isQuickJoiningOrStartingAGame) return;
const _gameId = gdjs.projectData.properties.projectUuid;
if (!_gameId) {
logger.error(
'The game ID is missing, the quick join lobby action cannot continue.'
);
return;
}
_quickJoinLobbyFailureReason = null;
_isQuickJoiningOrStartingAGame = true;
if (displayLoader) {
gdjs.multiplayerComponents.displayLoader(runtimeScene, true);
}
const quickJoinWithLobbyIDRelativeUrl = `/play/game/${_gameId}/public-lobby/${lobbyID}`;
try {
const lobby: Lobby = await gdjs.evtTools.network.retryIfFailed(
{ times: 2 },
() =>
fetchAsPlayer({
relativeUrl: quickJoinWithLobbyIDRelativeUrl,
method: 'GET',
dev: isUsingGDevelopDevelopmentEnvironment,
})
);
const isFull = lobby.players.length === lobby.maxPlayers;
if (isFull) {
logger.error('Lobby is full - cannot quick join it.');
_quickJoinLobbyJustFailed = true;
_quickJoinLobbyFailureReason = 'FULL';
onLobbyQuickJoinFinished(runtimeScene);
if (openLobbiesPageIfFailure) {
openLobbiesWindow(runtimeScene);
}
return;
}
if (lobby.status === 'playing') {
_actionAfterJoiningLobby = 'JOIN_GAME';
} else if (lobby.status === 'waiting') {
if (lobby.players.length === 0) {
_actionAfterJoiningLobby = 'START_GAME';
} else {
_actionAfterJoiningLobby = 'OPEN_LOBBY_PAGE';
}
} else {
throw new Error(`Lobby in wrong status: ${lobby.status}`);
}
handleJoinLobbyEvent(runtimeScene, lobbyID);
} catch (error) {
const errorCode = parseInt(error.message.match(/\d{3}/)?.[0]);
if (errorCode === 404) {
logger.error('Lobby does not exist.');
_quickJoinLobbyFailureReason = 'DOES_NOT_EXIST';
} else {
logger.error('An error occurred while joining a lobby:', error);
_quickJoinLobbyFailureReason = 'UNKNOWN';
}
_quickJoinLobbyJustFailed = true;
onLobbyQuickJoinFinished(runtimeScene);
if (openLobbiesPageIfFailure) {
openLobbiesWindow(runtimeScene);
}
}
};
const isQuickJoiningTooFast = () => {
const requestDoneAt = Date.now();
if (_lastQuickJoinRequestDoneAt) {
if (requestDoneAt - _lastQuickJoinRequestDoneAt < 500) {
_lastQuickJoinRequestDoneAt = requestDoneAt;
logger.warn(
'Last request to quick join a lobby was sent too little time ago. Ignoring this one.'
);
return true;
}
} else {
_lastQuickJoinRequestDoneAt = requestDoneAt;
}
return false;
};
const isNotCorrectlyAuthenticatedForQuickJoin = async (
runtimeScene: RuntimeScene
) => {
const playerId = gdjs.playerAuthentication.getUserId();
const playerToken = gdjs.playerAuthentication.getUserToken();
if (!playerId || !playerToken) {
_isWaitingForLogin = true;
const { status } =
await gdjs.playerAuthentication.openAuthenticationWindow(runtimeScene)
.promise;
_isWaitingForLogin = false;
if (status !== 'logged') {
return true;
}
}
return false;
};
export const authenticateAndQuickJoinWithLobbyID = async (
runtimeScene: gdjs.RuntimeScene,
lobbyID: string,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (isQuickJoiningTooFast()) {
return;
}
if (await isNotCorrectlyAuthenticatedForQuickJoin(runtimeScene)) {
return;
}
await quickJoinWithLobbyID(
runtimeScene,
lobbyID,
displayLoader,
openLobbiesPageIfFailure
);
};
export const authenticateAndQuickJoinLobby = async (
runtimeScene: gdjs.RuntimeScene,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (isQuickJoiningTooFast()) {
return;
}
if (await isNotCorrectlyAuthenticatedForQuickJoin(runtimeScene)) {
return;
}
await quickJoinLobby(
runtimeScene,
displayLoader,
openLobbiesPageIfFailure
);
};
export const isSearchingForLobbyToJoin = (
runtimeScene: gdjs.RuntimeScene
) => {
return _isQuickJoiningOrStartingAGame;
};
export const hasQuickJoinJustFailed = (runtimeScene: gdjs.RuntimeScene) => {
return _quickJoinLobbyJustFailed;
};
export const getQuickJoinFailureReason = () => {
return _quickJoinLobbyFailureReason;
};
/**
* Action to display the lobbies window to the user.
*/
export const openLobbiesWindow = async (
runtimeScene: gdjs.RuntimeScene
) => {
if (
isLobbiesWindowOpen(runtimeScene) ||
gdjs.playerAuthentication.isAuthenticationWindowOpen()
) {
return;
}
const _gameId = gdjs.projectData.properties.projectUuid;
if (!_gameId) {
handleLobbiesError(
runtimeScene,
'The game ID is missing, the lobbies window cannot be opened.'
);
return;
}
if (_isCheckingIfGameIsRegistered || _isWaitingForLogin) {
// The action is called multiple times, let's prevent that.
return;
}
// Create the lobbies container for the player to wait.
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleLobbiesError(
runtimeScene,
"The div element covering the game couldn't be found, the lobbies window cannot be displayed."
);
return;
}
const onLobbiesContainerDismissed = () => {
removeLobbiesContainer(runtimeScene);
};
const playerId = gdjs.playerAuthentication.getUserId();
const playerToken = gdjs.playerAuthentication.getUserToken();
if (!playerId || !playerToken) {
_isWaitingForLogin = true;
const { status } =
await gdjs.playerAuthentication.openAuthenticationWindow(runtimeScene)
.promise;
_isWaitingForLogin = false;
if (status === 'logged') {
openLobbiesWindow(runtimeScene);
}
return;
}
gdjs.multiplayerComponents.displayLobbies(
runtimeScene,
onLobbiesContainerDismissed
);
// If the game is registered, open the lobbies window.
// Otherwise, open the window indicating that the game is not registered.
if (_isGameRegistered === null) {
_isCheckingIfGameIsRegistered = true;
try {
const isGameRegistered = await checkIfGameIsRegistered(
runtimeScene.getGame(),
_gameId
);
_isGameRegistered = isGameRegistered;
} catch (error) {
_isGameRegistered = false;
logger.error(
'Error while checking if the game is registered:',
error
);
handleLobbiesError(
runtimeScene,
'Error while checking if the game is registered.'
);
return;
} finally {
_isCheckingIfGameIsRegistered = false;
}
}
const electron = runtimeScene.getGame().getRenderer().getElectron();
const wikiOpenAction = electron
? () =>
electron.shell.openExternal(
'https://wiki.gdevelop.io/gdevelop5/publishing/web'
)
: () =>
window.open(
'https://wiki.gdevelop.io/gdevelop5/publishing/web',
'_blank'
);
gdjs.multiplayerComponents.addTextsToLoadingContainer(
runtimeScene,
_isGameRegistered,
wikiOpenAction
);
if (_isGameRegistered) {
openLobbiesIframe(runtimeScene, _gameId);
}
};
/**
* Condition to check if the window is open, so that the game can be paused in the background.
*/
export const isLobbiesWindowOpen = function (
runtimeScene: gdjs.RuntimeScene
): boolean {
const lobbiesRootContainer =
gdjs.multiplayerComponents.getLobbiesRootContainer(runtimeScene);
return !!lobbiesRootContainer;
};
export const showLobbiesCloseButton = function (
runtimeScene: gdjs.RuntimeScene,
visible: boolean
) {
gdjs.multiplayerComponents.changeLobbiesWindowCloseActionVisibility(
runtimeScene,
visible
);
};
/**
* Remove the container displaying the lobbies window and the callback.
*/
export const removeLobbiesContainer = function (
runtimeScene: gdjs.RuntimeScene
) {
removeLobbiesCallbacks();
gdjs.multiplayerComponents.removeLobbiesContainer(runtimeScene);
};
/*
* Remove the lobbies callbacks.
*/
const removeLobbiesCallbacks = function () {
// Remove the lobbies callbacks.
if (_lobbiesMessageCallback) {
window.removeEventListener('message', _lobbiesMessageCallback, true);
_lobbiesMessageCallback = null;
}
};
/**
* Focus on game canvas to allow user to interact with it.
*/
const focusOnGame = function (runtimeScene: gdjs.RuntimeScene) {
const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas();
if (gameCanvas) gameCanvas.focus();
};
/**
* Action to allow the player to leave the lobby in-game.
*/
export const leaveGameLobby = async () => {
// Handle the case where the game has not started yet, so the player is in the lobby.
handleLeaveLobbyEvent();
// Handle the case where the game has started, so the player is in the game and connected to other players.
handleLobbyGameEnded();
};
}
}