Skip to content
Joël Deffner edited this page Sep 4, 2026 · 1 revision

P2P

Since v0.6.0. steam.p2p is the curated layer over ISteamNetworking: send bytes to another Steam user, read what they send back, and manage the session in between. Addressing is by Steam id and nothing else. There is no host, no port, no socket and no handshake to write.

Valve deprecated ISteamNetworking in favour of ISteamNetworkingMessages, and this binding cannot wrap the replacement. Every call on the new interface takes a SteamNetworkingIdentity, which is a C union. steam_api.json cannot describe a union, so that struct has no offset table and there is no supported way to fill the buffer the generated steam.networkingMessages asks for. The old interface is therefore the peer to peer path that works today. It still works: Valve keeps it alive for shipped games, over the same relay network.

The first packet from a new peer does not arrive. It raises P2PSessionRequest_t on the receiver instead, and nothing gets through until that side calls acceptSession, so subscribe with onSessionRequest before you expect traffic. The sending side needs none of this: sending to a peer accepts their session implicitly, so whoever speaks first is already done.

The P2P instance is created lazily and cached on the Steam object.

Two processes

The host accepts sessions and drains its channel on a timer. Steam queues packets while nobody reads, so a host that stops reading falls behind.

// host.ts
import { init } from 'steamwand.js';

const steam = init({ appId: 480 });
console.log('host is', steam.steamId());

steam.p2p.onSessionRequest((steamId) => steam.p2p.acceptSession(steamId));

setInterval(() => {
  for (const packet of steam.p2p.readAll()) {
    console.log(packet.steamId, 'said', packet.data.toString());
    steam.p2p.send(packet.steamId, 'pong');
  }
}, 50);
// client.ts
import { init } from 'steamwand.js';

const steam = init({ appId: 480 });
const host = 76561198000000000n; // the id host.ts printed

steam.p2p.send(host, 'ping');
setInterval(() => {
  for (const packet of steam.p2p.readAll()) console.log('host said', packet.data.toString());
}, 50);

Both processes need a running Steam client logged in as the account they claim to be. Two accounts on two machines is the honest test; the same account twice is not one. This is also why the package has no live test for this layer.

send

send(steamId: bigint, data: Buffer | string, opts?: { reliable?: boolean; channel?: number }): boolean

Sends one packet. A string is encoded as UTF-8. reliable (default true) picks k_EP2PSendReliable, which resends until the packet arrives and keeps its order; false picks k_EP2PSendUnreliable, which sends once and may be dropped or reordered. channel (default 0) must match the channel the peer reads.

Returns Steam's own answer. False means the packet was not queued at all, for example because the Steam id is not a valid user. True means queued, not delivered: delivery is what sessionState and onSessionConnectFail are for.

available

available(channel?: number): number | null

Size in bytes of the next packet waiting on channel (default 0), or null when nothing is waiting. read calls this itself.

read

read(channel?: number): { steamId: bigint; data: Buffer } | null

Reads the next packet, or null when nothing is waiting. The read buffer is allocated at exactly the size Steam reported, so a packet is never truncated and never over-allocated, and data is a copy you own.

readAll

readAll(channel?: number): { steamId: bigint; data: Buffer }[]

Drains everything waiting on channel, oldest first. This is the call a game loop makes once per frame.

acceptSession

acceptSession(steamId: bigint): void

Accepts a session another user asked for, from an onSessionRequest listener. Accept the peers you expect and ignore the rest: an unaccepted session costs nothing, and accepting an unknown Steam id lets that user send you traffic.

closeSession

closeSession(steamId: bigint): void

Closes the whole session with a peer, on every channel. Packets still queued for that peer are dropped. Call it when the player leaves, or Steam keeps the session open until the process exits.

closeChannel

closeChannel(steamId: bigint, channel: number): void

Closes one channel of a session and leaves the others open.

sessionState

sessionState(steamId: bigint): P2PSessionState | null

Reads the state of the session with one peer, or null when there is none. This is the diagnostic call: whether the connection came up, whether it goes through a Valve relay, and how much is still queued.

allowRelay

allowRelay(allow: boolean): void

Allows or forbids relaying packets through Valve's servers. Relaying is on by default and is what makes peer to peer work behind a NAT, at the cost of some latency. Turn it off only when your game has its own fallback.

onSessionRequest

onSessionRequest(listener: (steamId: bigint) => void): () => void

Subscribes to peers asking to open a session, and returns an unsubscribe function. Accept only the peers you expect; see acceptSession.

onSessionConnectFail

onSessionConnectFail(listener: (event: { steamId: bigint; error: number }) => void): () => void

Subscribes to sessions that failed to come up or dropped, and returns an unsubscribe function. This is the only signal that a peer went away: there is no disconnect event otherwise. error is an EP2PSessionError: 2 no rights to app, 4 timeout.

Types

P2PPacket

Field Type Meaning
steamId bigint Steam id of the peer that sent it.
data Buffer The payload bytes, exactly as long as Steam reported.

P2PSessionState

Field Type Meaning
connectionActive boolean True while packets can flow in both directions.
connecting boolean True while Steam is still opening the session.
error number EP2PSessionError: 0 none, 2 no rights to app, 4 timeout.
usingRelay boolean True when traffic goes through a Valve relay.
bytesQueued number Bytes still waiting to go out to this peer.
packetsQueued number Packets still waiting to go out to this peer.
remoteIp string Peer address as a dotted quad, empty when Steam does not know it.
remotePort number Peer port, 0 when Steam does not know it.

Errors

acceptSession, closeSession and closeChannel throw a plain Error when Steam returns false, which means an invalid Steam id. Nothing here throws a SteamResultError: ISteamNetworking has no EResult anywhere. send returns its false rather than throwing, because a refused packet is a normal thing to handle in a game loop.

What this layer does not do

  • ISteamNetworkingMessages, ISteamNetworkingSockets and ISteamNetworkingUtils. The generated classes exist on steam.networkingMessages, steam.networkingSockets and steam.networkingUtils, but their identity and address parameters are raw buffers with no layout to fill, for the union reason above.
  • The socket half of ISteamNetworking: CreateP2PConnectionSocket, SendDataOnSocket, GetSocketInfo and the rest, still on steam.networking. The packet half does the same job.
  • Finding the other player, which is Lobbies.

Flat API explains the calling convention.

Next: Lobbies, which is where the Steam ids you send to come from.

Clone this wiki locally