Skip to main content
The ORTC (Object Real-Time Communications) example demonstrates Pion WebRTC’s ORTC capabilities, providing fine-grained control over the WebRTC stack. Instead of using the high-level PeerConnection API, ORTC gives you direct access to ICE, DTLS, and SCTP transports for maximum flexibility.

Overview

ORTC is an alternative API to WebRTC that provides explicit control over the underlying transport layers. This example shows how to establish a DataChannel connection using the ORTC API, manually managing ICE gathering, DTLS handshake, and SCTP transport.

Key Features

  • Manual ICE transport management
  • Explicit DTLS and SCTP transport control
  • ICE role negotiation (controlling vs controlled)
  • Low-level signaling structure
  • DataChannel creation and handling via ORTC
  • HTTP server for easy SDP exchange (when acting as offerer)

ORTC vs WebRTC API

AspectWebRTC APIORTC API
AbstractionHigh-level, automaticLow-level, explicit
ICEAutomaticManual gathering/control
DTLSAutomaticManual start/configuration
ControlLimitedFull access to transports
ComplexitySimpleAdvanced
Use CaseStandard applicationsCustom protocols, debugging

How It Works

1

Create ICE Gatherer

Manually create and configure ICE gathering:
// Prepare ICE gathering options
iceOptions := webrtc.ICEGatherOptions{
    ICEServers: []webrtc.ICEServer{
        {URLs: []string{"stun:stun.l.google.com:19302"}},
    },
}

api := webrtc.NewAPI()

// Create the ICE gatherer
gatherer, err := api.NewICEGatherer(iceOptions)
if err != nil {
    panic(err)
}

// Handle candidates
gatherFinished := make(chan struct{})
gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) {
    if candidate == nil {
        close(gatherFinished)
    }
})

// Start gathering
if err = gatherer.Gather(); err != nil {
    panic(err)
}
<-gatherFinished
2

Construct Transport Stack

Build ICE, DTLS, and SCTP transports:
// Construct the ICE transport
ice := api.NewICETransport(gatherer)

// Construct the DTLS transport
dtls, err := api.NewDTLSTransport(ice, nil)
if err != nil {
    panic(err)
}

// Construct the SCTP transport
sctp := api.NewSCTPTransport(dtls)
3

Gather Local Parameters

Collect all parameters needed for signaling:
iceCandidates, err := gatherer.GetLocalCandidates()
if err != nil {
    panic(err)
}

iceParams, err := gatherer.GetLocalParameters()
if err != nil {
    panic(err)
}

dtlsParams, err := dtls.GetLocalParameters()
if err != nil {
    panic(err)
}

sctpCapabilities := sctp.GetCapabilities()

signal := Signal{
    ICECandidates:    iceCandidates,
    ICEParameters:    iceParams,
    DTLSParameters:   dtlsParams,
    SCTPCapabilities: sctpCapabilities,
}
4

Exchange Signaling

Exchange ORTC parameters with remote peer:
// Signal structure (not part of ORTC spec)
type Signal struct {
    ICECandidates    []webrtc.ICECandidate   `json:"iceCandidates"`
    ICEParameters    webrtc.ICEParameters    `json:"iceParameters"`
    DTLSParameters   webrtc.DTLSParameters   `json:"dtlsParameters"`
    SCTPCapabilities webrtc.SCTPCapabilities `json:"sctpCapabilities"`
}

// Send local signal
fmt.Println(encode(signal))

// Receive remote signal
remoteSignal := Signal{}
decode(readUntilNewline(), &remoteSignal)
5

Start Transports

Manually start each transport layer:
// Determine ICE role
iceRole := webrtc.ICERoleControlled
if isOffer {
    iceRole = webrtc.ICERoleControlling
}

// Set remote ICE candidates
if err = ice.SetRemoteCandidates(
    remoteSignal.ICECandidates,
); err != nil {
    panic(err)
}

// Start ICE transport
err = ice.Start(nil, remoteSignal.ICEParameters, &iceRole)
if err != nil {
    panic(err)
}

// Start DTLS transport
if err = dtls.Start(remoteSignal.DTLSParameters); err != nil {
    panic(err)
}

// Start SCTP transport
if err = sctp.Start(remoteSignal.SCTPCapabilities); err != nil {
    panic(err)
}
6

Create DataChannel

Create DataChannel using ORTC API:
// Offerer creates the data channel
if isOffer {
    var id uint16 = 1
    dcParams := &webrtc.DataChannelParameters{
        Label: "Foo",
        ID:    &id,
    }
    
    channel, err := api.NewDataChannel(sctp, dcParams)
    if err != nil {
        panic(err)
    }

    channel.OnMessage(func(msg webrtc.DataChannelMessage) {
        fmt.Printf(
            "Message from DataChannel '%s': '%s'\n",
            channel.Label(),
            string(msg.Data),
        )
    })
} else {
    // Answerer handles incoming data channels
    sctp.OnDataChannel(func(channel *webrtc.DataChannel) {
        fmt.Printf(
            "New DataChannel %s %d\n",
            channel.Label(),
            channel.ID(),
        )
        
        channel.OnMessage(func(msg webrtc.DataChannelMessage) {
            fmt.Printf(
                "Message from DataChannel '%s': '%s'\n",
                channel.Label(),
                string(msg.Data),
            )
        })
    })
}

Complete Source Code

package main

import (
    "bufio"
    "encoding/base64"
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/pion/randutil"
    "github.com/pion/webrtc/v4"
)

func main() {
    isOffer := flag.Bool("offer", false, "Act as the offerer if set")
    port := flag.Int("port", 8080, "http server port")
    flag.Parse()

    // Prepare ICE gathering options
    iceOptions := webrtc.ICEGatherOptions{
        ICEServers: []webrtc.ICEServer{
            {URLs: []string{"stun:stun.l.google.com:19302"}},
        },
    }

    api := webrtc.NewAPI()

    // Create ICE gatherer
    gatherer, err := api.NewICEGatherer(iceOptions)
    if err != nil {
        panic(err)
    }

    // Construct transports
    ice := api.NewICETransport(gatherer)
    dtls, err := api.NewDTLSTransport(ice, nil)
    if err != nil {
        panic(err)
    }
    sctp := api.NewSCTPTransport(dtls)

    // Handle incoming data channels (for answerer)
    sctp.OnDataChannel(func(channel *webrtc.DataChannel) {
        fmt.Printf("New DataChannel %s %d\n", channel.Label(), channel.ID())
        channel.OnOpen(handleOnOpen(channel))
        channel.OnMessage(func(msg webrtc.DataChannelMessage) {
            fmt.Printf(
                "Message from DataChannel '%s': '%s'\n",
                channel.Label(),
                string(msg.Data),
            )
        })
    })

    // Gather ICE candidates
    gatherFinished := make(chan struct{})
    gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) {
        if candidate == nil {
            close(gatherFinished)
        }
    })

    if err = gatherer.Gather(); err != nil {
        panic(err)
    }
    <-gatherFinished

    // Get local parameters
    iceCandidates, err := gatherer.GetLocalCandidates()
    if err != nil {
        panic(err)
    }

    iceParams, err := gatherer.GetLocalParameters()
    if err != nil {
        panic(err)
    }

    dtlsParams, err := dtls.GetLocalParameters()
    if err != nil {
        panic(err)
    }

    sctpCapabilities := sctp.GetCapabilities()

    s := Signal{
        ICECandidates:    iceCandidates,
        ICEParameters:    iceParams,
        DTLSParameters:   dtlsParams,
        SCTPCapabilities: sctpCapabilities,
    }

    iceRole := webrtc.ICERoleControlled

    // Exchange signaling
    fmt.Println(encode(s))
    remoteSignal := Signal{}

    if *isOffer {
        signalingChan := httpSDPServer(*port)
        decode(<-signalingChan, &remoteSignal)
        iceRole = webrtc.ICERoleControlling
    } else {
        decode(readUntilNewline(), &remoteSignal)
    }

    // Start transports
    if err = ice.SetRemoteCandidates(remoteSignal.ICECandidates); err != nil {
        panic(err)
    }

    err = ice.Start(nil, remoteSignal.ICEParameters, &iceRole)
    if err != nil {
        panic(err)
    }

    if err = dtls.Start(remoteSignal.DTLSParameters); err != nil {
        panic(err)
    }

    if err = sctp.Start(remoteSignal.SCTPCapabilities); err != nil {
        panic(err)
    }

    // Create data channel (offerer only)
    if *isOffer {
        var id uint16 = 1
        dcParams := &webrtc.DataChannelParameters{
            Label: "Foo",
            ID:    &id,
        }
        
        channel, err := api.NewDataChannel(sctp, dcParams)
        if err != nil {
            panic(err)
        }

        go handleOnOpen(channel)()
        channel.OnMessage(func(msg webrtc.DataChannelMessage) {
            fmt.Printf(
                "Message from DataChannel '%s': '%s'\n",
                channel.Label(),
                string(msg.Data),
            )
        })
    }

    select {}
}

Important Implementation Details

ORTC requires explicit ICE role assignment:
iceRole := webrtc.ICERoleControlled
if isOffer {
    iceRole = webrtc.ICERoleControlling
}
Roles:
  • Controlling: Acts as offerer, initiates connectivity checks
  • Controlled: Acts as answerer, responds to checks
Both sides must agree on roles or connection will fail.
The Signal struct is NOT part of the ORTC specification:
type Signal struct {
    ICECandidates    []webrtc.ICECandidate
    ICEParameters    webrtc.ICEParameters
    DTLSParameters   webrtc.DTLSParameters
    SCTPCapabilities webrtc.SCTPCapabilities
}
ORTC doesn’t define a signaling format - you’re free to exchange this information however you want:
  • JSON over WebSocket
  • Protocol Buffers
  • Custom binary format
  • HTTP POST (as shown in example)
Transports must be started in order:
  1. ICE Transport - Network connectivity
  2. DTLS Transport - Security/encryption
  3. SCTP Transport - DataChannel support
Starting out of order will cause connection failures.
Only the offerer should create DataChannels initially:
if isOffer {
    channel, err := api.NewDataChannel(sctp, dcParams)
    // ...
} else {
    sctp.OnDataChannel(func(channel *webrtc.DataChannel) {
        // Handle incoming channel
    })
}
The answerer handles channels via the OnDataChannel callback.

Running the Example

Two Terminal Setup

1

Start as answerer (Terminal 1)

cd examples/ortc
go run main.go
Copy the base64 encoded signal that appears
2

Start as offerer (Terminal 2)

go run main.go --offer --port 8081
Copy the base64 encoded signal
3

Exchange signals

  • Paste offerer’s signal into answerer’s terminal
  • Paste answerer’s signal via curl to offerer:
curl -X POST -d "<answerer-signal>" http://localhost:8081
4

Watch messages

Both terminals will show random messages being exchanged every 5 seconds

When to Use ORTC

Custom Protocols

Building custom signaling or connection protocols that don’t fit WebRTC’s model

Debugging

Deep debugging of connection issues with fine-grained control over each layer

Research

Academic research or experimentation with transport protocols

Optimization

Performance optimization requiring precise control over network behavior
For most applications, the standard WebRTC PeerConnection API is recommended. Use ORTC only when you need the additional control and complexity it provides.

Advantages & Trade-offs

Advantages:
  • Full control over transport layers
  • Custom signaling formats
  • Better debugging visibility
  • Fine-grained configuration
Trade-offs:
  • More complex code
  • More error-prone
  • Requires deeper protocol knowledge
  • Limited browser support (mostly Chromium)

Build docs developers (and LLMs) love