Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ const network = new Network('<your-game-id>')
```

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', () => {
Expand Down
28 changes: 28 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@ interface LobbyOptions {
##### `join(code: string, password?: string): Promise<void>`
Joins an existing lobby.

##### `joinOrCreate(settings?: JoinOrCreateSettings): Promise<LobbyListEntry | undefined>`
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<LobbySettings, 'public' | 'password'> {
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<void>`
Leaves the current lobby without closing the network.

Expand Down
168 changes: 168 additions & 0 deletions features/join-or-create.feature
Original file line number Diff line number Diff line change
@@ -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": <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"
22 changes: 22 additions & 0 deletions features/support/steps/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
81 changes: 64 additions & 17 deletions internal/signaling/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -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
}
Expand Down
Loading
Loading