diff --git a/README.md b/README.md index b9604ce..395a981 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ const network = new Network('') ``` 3. Create or join a lobby: + +For automatic matchmaking, join a random available public lobby or create one atomically: + +```javascript +network.on('ready', async () => { + const lobby = await network.joinOrCreate({ maxPlayers: 4 }) + console.log('Joined lobby:', lobby?.code) +}) +``` + +Alternatively, create or join a specific lobby: ```js // Create a new lobby network.on('ready', () => { diff --git a/docs/api-reference.md b/docs/api-reference.md index 2290e57..6e117c0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -38,6 +38,34 @@ interface LobbyOptions { ##### `join(code: string, password?: string): Promise` Joins an existing lobby. +##### `joinOrCreate(settings?: JoinOrCreateSettings): Promise` +Joins a random public, password-free, nonempty lobby with available capacity for this game, or creates one atomically. Simultaneous join-or-create requests share lobbies without exceeding their capacity. + +```typescript +interface JoinOrCreateSettings extends Omit { + filter?: object; +} + +const lobby = await network.joinOrCreate({ + filter: { mode: "teams" }, + customData: { mode: "teams" }, + maxPlayers: 4 +}); +``` + +Accepts the same settings as `create()`, except `public` and `password`, plus an optional `filter`. + +- `filter`: Selects existing lobbies using the same fields and MongoDB-style operators as `list()`. Invalid filters reject the request. +- `codeFormat`, `maxPlayers`, `customData`, `canUpdateBy`: Apply only when creating a lobby. Defaults match `create()`. + +Filters use `code`, `playerCount`, `createdAt`, `updatedAt`, and `latency` for lobby properties. Other fields refer to custom data. Latency is estimated from the joining player's location. + +If no lobby matches, creates a public, password-free lobby. Filter values are not copied into its custom data. Set `customData` explicitly, as shown above. + +Returns `undefined` when the network is closing or not ready. Existing lobby and connection events work as usual. + +Requires signaling server support. Older servers return an error. + ##### `leave(): Promise` Leaves the current lobby without closing the network. diff --git a/features/join-or-create.feature b/features/join-or-create.feature new file mode 100644 index 0000000..d142bff --- /dev/null +++ b/features/join-or-create.feature @@ -0,0 +1,168 @@ +Feature: Players can atomically find or create a public lobby + + Background: + Given the "signaling" backend is running + + Scenario: Simultaneous join-or-create requests connect players in the same lobby + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + + When "blue,yellow" join or create simultaneously with a maximum of 2 players + Then "blue" receives the network event "lobby" with the argument "19yrzmetd2bn7" + And "yellow" receives the network event "lobby" with the argument "19yrzmetd2bn7" + And "blue" receives the network event "connected" with the argument "[Peer: h5yzwyizlwao]" + And "yellow" receives the network event "connected" with the argument "[Peer: 1u8fw4aph5ypt]" + + When "blue" boardcasts "Hello, world!" over the reliable channel + Then "yellow" receives the network event "message" with the arguments "[Peer: 1u8fw4aph5ypt]", "reliable" and "Hello, world!" + + When "blue" requests lobbies with: + """json + {} + """ + Then "blue" should have received only these lobbies: + | code | playerCount | maxPlayers | public | hasPassword | + | 19yrzmetd2bn7 | 2 | 2 | true | false | + + Scenario: Creation settings apply only when join-or-create creates a lobby + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + When "blue" joins or creates a lobby with these settings: + """json + { + "codeFormat": "short", + "maxPlayers": 3, + "customData": { + "mode": "teams" + }, + "canUpdateBy": "none" + } + """ + Then "blue" receives the network event "lobby" with the argument "SHC6" + + When "yellow" creates a network for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + Then "yellow" receives the network event "ready" + When "yellow" joins or creates a lobby with these settings: + """json + { + "codeFormat": "default", + "maxPlayers": 1, + "customData": { + "mode": "solo" + }, + "canUpdateBy": "anyone" + } + """ + Then "yellow" receives the network event "lobby" with the argument "SHC6" + + When "blue" requests lobbies with: + """json + {} + """ + Then "blue" should have received only these lobbies: + | code | playerCount | maxPlayers | public | hasPassword | customData | canUpdateBy | + | SHC6 | 2 | 3 | true | false | {"mode":"teams"} | none | + + Scenario: Join-or-create filters existing lobbies by custom data + Given the "testproxy" backend is running + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And these lobbies exist: + | code | game | playerCount | custom_data | public | + | 0qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"solo","level":5} | true | + | 1qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"teams","level":1} | true | + | 2qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"teams","level":5} | true | + | 3qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"teams","level":5} | false | + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": { + "$or": [ + { + "mode": "teams" + }, + { + "mode": "duo" + } + ], + "level": { + "$gte": 3 + } + }, + "customData": { + "mode": "unused" + } + } + """ + Then "blue" receives the network event "lobby" with the argument "2qva9vyurwbbl" + + Scenario: Join-or-create creates a lobby when no custom data matches + Given the "testproxy" backend is running + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And these lobbies exist: + | code | game | playerCount | custom_data | public | + | 0qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"solo"} | true | + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": { + "mode": "teams" + }, + "codeFormat": "short", + "customData": { + "mode": "teams", + "level": 5 + } + } + """ + Then "blue" receives the network event "lobby" with the argument "SHC6" + When "blue" requests lobbies with: + """json + { + "mode": "teams" + } + """ + Then "blue" should have received only these lobbies: + | code | playerCount | customData | + | SHC6 | 1 | {"level":5,"mode":"teams"} | + + Scenario Outline: Join-or-create uses the same lobby fields as list + Given the "testproxy" backend is running + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And these lobbies exist: + | code | game | playerCount | created_at | updated_at | custom_data | public | + | 2qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 2 | 2026-01-01T00:00:00Z | 2026-01-02T00:00:00Z | {"code":"different","playerCount":99,"createdAt":null,"updatedAt":null,"latency":999} | true | + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": + } + """ + Then "blue" receives the network event "lobby" with the argument "2qva9vyurwbbl" + + Examples: + | filter | + | {"code":"2qva9vyurwbbl"} | + | {"playerCount":{"$gte":2,"$lt":3}} | + | {"createdAt":{"$gte":"2026-01-01","$lt":"2026-01-02"}} | + | {"updatedAt":{"$gte":"2026-01-02","$lt":"2026-01-03"}} | + | {"latency":{"$lte":250}} | + + Scenario: Join-or-create filters latency from the joining player's location + Given the "testproxy" backend is running + And these lobbies exist: + | code | game | peers | public | + | 0qva9vyurwbbl | 123e4567-e89b-12d3-a456-426614174000 | {peerA} | true | + And these peers exist: + | peer | game | country | region | + | peerA | 123e4567-e89b-12d3-a456-426614174000 | US | US-CA | + And "blue" is connected as "1u8fw4aph5ypt" with country,region as "US","US-CA" and ready for game "123e4567-e89b-12d3-a456-426614174000" + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": { + "latency": { + "$lt": 100 + } + } + } + """ + Then "blue" receives the network event "lobby" with the argument "0qva9vyurwbbl" diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index 15c37ed..0e7a931 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -194,6 +194,28 @@ When('{string} creates a lobby with these settings:', async function (this: Worl await player.network.create(settings) }) +When('{string} joins or creates a lobby with these settings:', async function (this: World, playerName: string, settingsBlob: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + const settings = JSON.parse(settingsBlob) + await player.network.joinOrCreate(settings) +}) + +When('{string} join or create simultaneously with a maximum of {int} players', async function (this: World, playerNamesRaw: string, maxPlayers: number) { + await Promise.all(playerNamesRaw.split(',').map(async playerName => { + const player = this.players.get(playerName.trim()) + if (player == null) { + throw new Error('no such player') + } + const lobby = await player.network.joinOrCreate({ maxPlayers }) + if (lobby === undefined || lobby.code !== player.network.currentLobby) { + throw new Error('join-or-create did not return the current lobby') + } + })) +}) + When('{string} connects to the lobby {string}', async function (this: World, playerName: string, lobbyCode: string) { const player = this.players.get(playerName) if (player == null) { diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index bb12f35..5833b34 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -159,6 +159,16 @@ func (p *Peer) HandlePacket(ctx context.Context, typ string, raw []byte) error { return fmt.Errorf("unable to handle packet: %w", err) } + case "joinOrCreate": + packet := JoinOrCreatePacket{} + if err := json.Unmarshal(raw, &packet); err != nil { + return fmt.Errorf("unable to unmarshal json: %w", err) + } + err = p.HandleJoinOrCreatePacket(ctx, packet) + if err != nil { + return fmt.Errorf("unable to handle packet: %w", err) + } + case "lobbyUpdate": packet := LobbyUpdatePacket{} if err := json.Unmarshal(raw, &packet); err != nil { @@ -438,17 +448,7 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro attempts := 0 for ; attempts < 20; attempts++ { - if packet.CodeFormat == "short" && attempts < 19 { - // The first 15 attempts will try to generate a 4 character code. - // After that, it will try to generate a 5 character code. - length := 4 - if attempts > 15 { - length = 5 - } - p.Lobby = util.GenerateShortLobbyCode(ctx, length) - } else { - p.Lobby = util.GenerateLobbyCode(ctx) - } + p.Lobby = util.GenerateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempts) err := p.store.CreateLobby(ctx, p.Game, p.Lobby, p.ID, stores.LobbyOptions{ Public: &packet.Public, @@ -491,7 +491,6 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro } func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { - logger := logging.GetLogger(ctx) if p.ID == "" { return fmt.Errorf("peer not connected") } @@ -522,11 +521,58 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { return err } - p.Lobby = packet.Lobby + return p.finishJoin(ctx, packet.RequestID, packet.Lobby, existingPeers, "joined") +} + +func (p *Peer) HandleJoinOrCreatePacket(ctx context.Context, packet JoinOrCreatePacket) error { + if p.ID == "" { + return fmt.Errorf("peer not connected") + } + if p.Lobby != "" { + return fmt.Errorf("already in a lobby %s:%s as %s", p.Game, p.Lobby, p.ID) + } + + if packet.CanUpdateBy == "" { + packet.CanUpdateBy = stores.CanUpdateByCreator + } else if packet.CanUpdateBy != "" { + if packet.CanUpdateBy != stores.CanUpdateByCreator && + packet.CanUpdateBy != stores.CanUpdateByLeader && + packet.CanUpdateBy != stores.CanUpdateByAnyone && + packet.CanUpdateBy != stores.CanUpdateByNone { + return fmt.Errorf("invalid canUpdateBy value") + } + } + + maxPlayers := DefaultMaxPlayers + if packet.MaxPlayers != nil { + maxPlayers = *packet.MaxPlayers + } + if maxPlayers < 0 { + return fmt.Errorf("invalid maxPlayers value") + } + + lobbyCode, existingPeers, created, err := p.store.JoinOrCreateLobby(ctx, p.Game, p.ID, p.Country, p.Region, packet.Filter, packet.CodeFormat, stores.LobbyOptions{ + CustomData: &packet.CustomData, + CanUpdateBy: &packet.CanUpdateBy, + MaxPlayers: &maxPlayers, + }) + if err != nil { + return err + } + + action := "joined" + if created { + action = "created" + } + return p.finishJoin(ctx, packet.RequestID, lobbyCode, existingPeers, action) +} + +func (p *Peer) finishJoin(ctx context.Context, requestID, lobbyCode string, existingPeers []string, action string) error { + p.Lobby = lobbyCode p.store.Subscribe(ctx, p.ForwardMessage, p.Game, p.Lobby, p.ID) // Lobby might be empty when joining, then you need to become the leader. - _, err = p.doLeaderElectionAndPublish(ctx) + _, err := p.doLeaderElectionAndPublish(ctx) if err != nil { return err } @@ -537,7 +583,7 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { } err = p.Send(ctx, JoinedPacket{ - RequestID: packet.RequestID, + RequestID: requestID, Type: "joined", LobbyCode: p.Lobby, // backwards compatibility LobbyInfo: lobby, @@ -557,12 +603,13 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { } } - logger.Debug("joined lobby", + logger := logging.GetLogger(ctx) + logger.Debug(action+" lobby", zap.String("game", p.Game), zap.String("lobby", p.Lobby), zap.String("peer", p.ID), zap.Strings("peers", lobby.Peers)) - go metrics.Record(ctx, "lobby", "joined", p.Game, p.ID, p.Lobby) + go metrics.Record(ctx, "lobby", action, p.Game, p.ID, p.Lobby) return nil } diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index eabf5ab..d5c9c19 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "hash/fnv" "math/rand" "os" "regexp" @@ -276,6 +277,111 @@ func (s *PostgresStore) JoinLobby(ctx context.Context, game, lobbyCode, peerID, return peerlist, nil } +func (s *PostgresStore) JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter, codeFormat string, options LobbyOptions) (string, []string, bool, error) { + if len(peerID) > 20 { + return "", nil, false, ErrInvalidPeerID + } + + if lobbyFilter == "" { + lobbyFilter = "{}" + } + where, values, err := s.filterConverter.Convert([]byte(lobbyFilter), 4) + if err != nil { + return "", nil, false, fmt.Errorf("invalid filter: %w", err) + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return "", nil, false, err + } + defer tx.Rollback(context.Background()) //nolint:errcheck + + h := fnv.New64a() + _, _ = h.Write([]byte(game)) + hash := int64(h.Sum64()) + + // Lock the game before looking for a lobby, including when none exists yet. + // + // The SELECT FOR UPDATE below will only lock an existing lobby row, it won't help us + // with the race condition where 2 players both don't find a lobby and try to create one simultaneously. + // + // pg_advisory_xact_lock automatically releases the lock at the end of the transaction. + _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, hash) + if err != nil { + return "", nil, false, err + } + + var lobbyCode string + var peerlist []string + err = tx.QueryRow(ctx, ` + SELECT code, peers + FROM ( + SELECT *, + COALESCE(ARRAY_LENGTH(peers, 1), 0) AS "playerCount", + created_at AS "createdAt", + updated_at AS "updatedAt", + lobby_latency_estimate(peers, $2, $3) AS latency + FROM lobbies + WHERE game = $1 + ) AS game_lobbies + WHERE public = true + AND password IS NULL + AND COALESCE(ARRAY_LENGTH(peers, 1), 0) > 0 + AND (max_players = 0 OR ARRAY_LENGTH(peers, 1) < max_players) + AND (`+where+`) + ORDER BY RANDOM() + LIMIT 1 + FOR UPDATE + `, append([]any{game, country, region}, values...)...).Scan(&lobbyCode, &peerlist) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return "", nil, false, err + } + + exists := !errors.Is(err, pgx.ErrNoRows) + now := util.NowUTC(ctx) + if !exists { + for attempts := range 20 { + lobbyCode = util.GenerateLobbyCodeWithFormat(ctx, codeFormat, attempts) + res, err := tx.Exec(ctx, ` + INSERT INTO lobbies (code, game, peers, public, created_at, updated_at, leader, term, can_update_by, creator, max_players, custom_data) + VALUES ($1, $2, $3, true, $4, $4, $5, 1, $6, $5, $7, $8) + ON CONFLICT DO NOTHING + `, lobbyCode, game, []string{peerID}, now, peerID, options.CanUpdateBy, options.MaxPlayers, options.CustomData) + if err != nil { + return "", nil, false, err + } + if res.RowsAffected() > 0 { + break + } + if attempts == 19 { + return "", nil, false, ErrLobbyExists + } + } + } else { + if slices.Contains(peerlist, peerID) { + return "", nil, false, ErrAlreadyInLobby + } + + _, err = tx.Exec(ctx, ` + UPDATE lobbies + SET + peers = array_append(peers, $1), + updated_at = $2 + WHERE code = $3 + AND game = $4 + `, peerID, now, lobbyCode, game) + if err != nil { + return "", nil, false, err + } + } + + if err := tx.Commit(ctx); err != nil { + return "", nil, false, err + } + + return lobbyCode, peerlist, !exists, nil +} + func (s *PostgresStore) LeaveLobby(ctx context.Context, game, lobbyCode, peerID string) error { now := util.NowUTC(ctx) diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index 97eda97..9a898ca 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -28,6 +28,7 @@ type Store interface { GetPlayerCounts(ctx context.Context, game, country, region, filter string) (PlayerCounts, error) CreateLobby(ctx context.Context, Game, LobbyCode, PeerID string, options LobbyOptions) error JoinLobby(ctx context.Context, game, lobby, id, password string) ([]string, error) + JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter, codeFormat string, options LobbyOptions) (string, []string, bool, error) LeaveLobby(ctx context.Context, game, lobby, id string) error GetLobby(ctx context.Context, game, lobby string) (Lobby, error) ListLobbies(ctx context.Context, game string, country, region string, filter, sort string, limit int) ([]Lobby, error) diff --git a/internal/signaling/types.go b/internal/signaling/types.go index 4271c29..1c4d784 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -77,6 +77,18 @@ type JoinPacket struct { Password string `json:"password"` } +type JoinOrCreatePacket struct { + Filter string `json:"filter"` + + RequestID string `json:"rid"` + Type string `json:"type"` + + CodeFormat string `json:"codeFormat"` + MaxPlayers *int `json:"maxPlayers"` + CustomData map[string]any `json:"customData"` + CanUpdateBy string `json:"canUpdateBy"` +} + type JoinedPacket struct { RequestID string `json:"rid"` Type string `json:"type"` diff --git a/internal/util/identifiers.go b/internal/util/identifiers.go index a6034a0..7a1749a 100644 --- a/internal/util/identifiers.go +++ b/internal/util/identifiers.go @@ -69,3 +69,18 @@ func GenerateShortLobbyCode(ctx context.Context, chars int) string { } return strings.Join(ss, "") } + +// GenerateLobbyCodeWithFormat generates a lobby code based on the specified format. +// It uses longer codes when earlier attempts collided. +func GenerateLobbyCodeWithFormat(ctx context.Context, format string, attempt int) string { + if format == "short" && attempt < 19 { + length := 4 + if attempt > 15 { + length = 5 + } + + return GenerateShortLobbyCode(ctx, length) + } + + return GenerateLobbyCode(ctx) +} diff --git a/lib/network.ts b/lib/network.ts index ce8274d..f8943c3 100644 --- a/lib/network.ts +++ b/lib/network.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'eventemitter3' import { DefaultDataChannels, DefaultRTCConfiguration, DefaultSignalingURL } from '.' -import { LobbyListEntry, LobbySettings, PeerConfiguration, PlayerCounts } from './types' +import { LobbyListEntry, LobbySettings, PeerConfiguration, PlayerCounts, JoinOrCreateSettings } from './types' import Signaling, { SignalingError } from './signaling' import Peer from './peer' import Credentials from './credentials' @@ -109,6 +109,21 @@ export default class Network extends EventEmitter { return undefined } + async joinOrCreate (settings?: JoinOrCreateSettings): Promise { + if (this._closing || this.signaling.receivedID === undefined) { + return undefined + } + const reply = await this.signaling.request({ + type: 'joinOrCreate', + ...settings, + filter: settings?.filter != null ? JSON.stringify(settings.filter) : undefined + }) + if (reply.type === 'joined') { + return reply.lobbyInfo + } + return undefined + } + async setLobbySettings (settings: LobbySettings): Promise { if (this._closing || this.signaling.receivedID === undefined) { return new Error('network is closing or not connected') diff --git a/lib/types.ts b/lib/types.ts index d52ebe4..6a831c4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -16,6 +16,10 @@ export interface LobbySettings { canUpdateBy?: 'creator' | 'leader' | 'anyone' | 'none' } +export interface JoinOrCreateSettings extends Omit { + filter?: object // MongoDB-style filter on existing lobbies, with the same fields as list(). +} + export interface LobbyListEntry { code: string public: boolean @@ -65,6 +69,7 @@ export type SignalingPacketTypes = | GetPlayerCountsPacket | PlayerCountsPacket | PingPacket +| JoinOrCreatePacket | WelcomePacket export interface PingPacket extends Base { @@ -115,6 +120,11 @@ export interface JoinPacket extends Base { password?: string } +export interface JoinOrCreatePacket extends Base, Omit { + type: 'joinOrCreate' + filter?: string +} + export interface JoinedPacket extends Base { type: 'joined' lobbyInfo: LobbyListEntry