diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..f7036b6
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,34 @@
+# WebRtcNet Domain Glossary
+
+This file is a glossary of canonical terms used in the WebRtcNet codebase.
+It contains **no implementation details** — only term definitions.
+
+---
+
+## Terms
+
+### Host
+A peer that initiates a WebRTC session by opening a TCP listener and waiting for
+an incoming connection before creating an SDP offer.
+
+### Guest
+A peer that joins a WebRTC session by dialling the Host's IP address and port,
+then responding to the Host's SDP offer with an SDP answer.
+
+### Signaling
+The out-of-band exchange of SDP offers/answers and ICE candidates between two
+peers. In this repository the term refers specifically to message exchange; it
+does not imply a server. BasicVideoChat uses direct TCP signaling.
+
+### SignalingMessage
+A newline-delimited JSON envelope carrying one of: `Offer`, `Answer`,
+`Candidate`, or `Bye`.
+
+### BasicVideoChat
+The first example application. Demonstrates a peer-to-peer audio/video call over
+a direct TCP signaling channel, targeting both .NET 10 and .NET Framework 4.8.
+
+### WpfVideoRenderer
+A prototype `VideoRenderer` implementation backed by a WPF `WriteableBitmap`.
+Lives in `examples/BasicVideoChat` until a proper `WebRtcNet.Wpf` renderer
+assembly is created (see issue #36).
diff --git a/WebRtcNet.slnx b/WebRtcNet.slnx
index dcd05ba..a0e3a7f 100644
--- a/WebRtcNet.slnx
+++ b/WebRtcNet.slnx
@@ -25,6 +25,9 @@
+
+
+
diff --git a/examples/BasicVideoChat/App.xaml b/examples/BasicVideoChat/App.xaml
new file mode 100644
index 0000000..a49332c
--- /dev/null
+++ b/examples/BasicVideoChat/App.xaml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/examples/BasicVideoChat/App.xaml.cs b/examples/BasicVideoChat/App.xaml.cs
new file mode 100644
index 0000000..eaa6ad8
--- /dev/null
+++ b/examples/BasicVideoChat/App.xaml.cs
@@ -0,0 +1,7 @@
+using System.Windows;
+
+namespace BasicVideoChat;
+
+public partial class App : Application
+{
+}
diff --git a/examples/BasicVideoChat/BasicVideoChat.csproj b/examples/BasicVideoChat/BasicVideoChat.csproj
new file mode 100644
index 0000000..2de9440
--- /dev/null
+++ b/examples/BasicVideoChat/BasicVideoChat.csproj
@@ -0,0 +1,22 @@
+
+
+ WinExe
+ net10.0-windows;net48
+ true
+ Debug;Release
+ x64;x86;ARM64
+ latest
+ enable
+ BasicVideoChat
+ A basic WebRTC peer-to-peer video chat example using WebRtcNet
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/BasicVideoChat/MainWindow.xaml b/examples/BasicVideoChat/MainWindow.xaml
new file mode 100644
index 0000000..5d80e69
--- /dev/null
+++ b/examples/BasicVideoChat/MainWindow.xaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/BasicVideoChat/MainWindow.xaml.cs b/examples/BasicVideoChat/MainWindow.xaml.cs
new file mode 100644
index 0000000..df1d275
--- /dev/null
+++ b/examples/BasicVideoChat/MainWindow.xaml.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using BasicVideoChat.Signaling;
+using WebRtcNet;
+using WebRtcNet.Media;
+
+namespace BasicVideoChat;
+
+public partial class MainWindow : Window
+{
+ private const int DefaultPort = 7777;
+
+ // Default STUN server for NAT traversal. Works for both LAN and internet connections.
+ // For LAN-only use you can pass an empty RtcConfiguration().
+ // Replace with your own STUN/TURN server if needed.
+ // See: https://webrtc.org/getting-started/turn-server
+ private static readonly RtcConfiguration DefaultConfiguration = new(
+ new[] { new RtcIceServer("stun:stun.l.google.com:19302") });
+
+ private WebRtcInterop.RtcPeerConnection? _peerConnection;
+ private TcpSignalingChannel? _signaling;
+ private MediaStream? _localStream;
+ private MediaStreamTrack? _audioTrack;
+ private MediaStreamTrack? _videoTrack;
+ private WpfVideoRenderer? _localRenderer;
+ private WpfVideoRenderer? _remoteRenderer;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ private bool IsHost => HostRadio.IsChecked == true;
+
+ private void HostRadio_Checked(object sender, RoutedEventArgs e) =>
+ IpBox.IsEnabled = false;
+
+ private void GuestRadio_Checked(object sender, RoutedEventArgs e) =>
+ IpBox.IsEnabled = true;
+
+ private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
+ {
+ ConnectBtn.IsEnabled = false;
+ try
+ {
+ await StartCallAsync();
+ HangUpBtn.IsEnabled = true;
+ MuteBtn.IsEnabled = true;
+ CameraBtn.IsEnabled = true;
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Error: {ex.Message}");
+ ConnectBtn.IsEnabled = true;
+ }
+ }
+
+ private async void HangUpBtn_Click(object sender, RoutedEventArgs e) =>
+ await HangUpAsync();
+
+ private void MuteBtn_Click(object sender, RoutedEventArgs e)
+ {
+ if (_audioTrack != null)
+ _audioTrack.Enabled = MuteBtn.IsChecked != true;
+ }
+
+ private void CameraBtn_Click(object sender, RoutedEventArgs e)
+ {
+ if (_videoTrack != null)
+ _videoTrack.Enabled = CameraBtn.IsChecked != true;
+ }
+
+ private async Task StartCallAsync()
+ {
+ SetStatus("Acquiring media...");
+
+ var mediaDevices = new WebRtcInterop.Media.MediaDevices();
+ _localStream = await mediaDevices.GetUserMedia(new MediaStreamConstraints(true, true));
+
+ _audioTrack = _localStream.GetAudioTracks().FirstOrDefault();
+ _videoTrack = _localStream.GetVideoTracks().FirstOrDefault();
+
+ _localRenderer = new WpfVideoRenderer(LocalVideo);
+ _remoteRenderer = new WpfVideoRenderer(RemoteVideo);
+ // TODO: Attach _localRenderer to _videoTrack once VideoRenderer interface is expanded.
+
+ // NOTE: Many WebRtcInterop methods currently throw NotImplementedException — this example
+ // will not run end-to-end until they are implemented. SetLocalDescription and
+ // AddIceCandidate are the minimum required for a basic call flow.
+ _peerConnection = new WebRtcInterop.RtcPeerConnection(DefaultConfiguration);
+ _peerConnection.OnIceCandidate += OnIceCandidate;
+ _peerConnection.OnTrack += OnTrack;
+ _peerConnection.OnConnectionStateChange += OnConnectionStateChange;
+
+ foreach (var track in _localStream.GetTracks())
+ _peerConnection.AddTrack(track, _localStream);
+
+ _signaling = new TcpSignalingChannel();
+ _signaling.MessageHandler = OnSignalingMessageAsync;
+ _signaling.Disconnected += () => Dispatcher.BeginInvoke(async () => await HangUpAsync());
+
+ if (IsHost)
+ {
+ var port = int.TryParse(PortBox.Text, out var p) ? p : DefaultPort;
+ SetStatus($"Listening on port {port}...");
+ await _signaling.ListenAsync(port);
+ SetStatus("Guest connected. Creating offer...");
+
+ // Drive the offer explicitly here rather than relying on OnNegotiationNeeded,
+ // since we control when the signaling channel is ready.
+ var offer = await _peerConnection.CreateOffer();
+ await _peerConnection.SetLocalDescription(offer);
+ await _signaling.SendAsync(new SignalingMessage { Type = SignalingMessageType.Offer, Sdp = offer.Sdp });
+ SetStatus("Offer sent. Waiting for answer...");
+ }
+ else
+ {
+ var host = IpBox.Text.Trim();
+ var port = int.TryParse(PortBox.Text, out var p) ? p : DefaultPort;
+ SetStatus($"Connecting to {host}:{port}...");
+ await _signaling.ConnectAsync(host, port);
+ SetStatus("Connected. Waiting for offer...");
+ }
+ }
+
+ // MessageHandler is awaited by TcpSignalingChannel before the next message is dispatched,
+ // ensuring that SetRemoteDescription always completes before any AddIceCandidate call.
+ private async Task OnSignalingMessageAsync(SignalingMessage message)
+ {
+ try
+ {
+ switch (message.Type)
+ {
+ case SignalingMessageType.Offer:
+ await _peerConnection!.SetRemoteDescription(
+ new RtcSessionDescription(RtcSdpType.Offer, message.Sdp!));
+ var answer = await _peerConnection.CreateAnswer();
+ await _peerConnection.SetLocalDescription(answer);
+ await _signaling!.SendAsync(new SignalingMessage
+ {
+ Type = SignalingMessageType.Answer,
+ Sdp = answer.Sdp
+ });
+ SetStatus("Answer sent.");
+ break;
+
+ case SignalingMessageType.Answer:
+ await _peerConnection!.SetRemoteDescription(
+ new RtcSessionDescription(RtcSdpType.Answer, message.Sdp!));
+ SetStatus("Answer received.");
+ break;
+
+ case SignalingMessageType.Candidate:
+ await _peerConnection!.AddIceCandidate(new RtcIceCandidate(
+ message.Candidate!, message.SdpMid, message.SdpMLineIndex));
+ break;
+
+ case SignalingMessageType.Bye:
+ Dispatcher.BeginInvoke(async () => await HangUpAsync());
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Signaling error: {ex.Message}");
+ }
+ }
+
+ private void OnIceCandidate(object? sender, RtcIceCandidateEventArgs e)
+ {
+ // Capture _signaling before the fire-and-forget to guard against a concurrent HangUp.
+ var sig = _signaling;
+ if (sig != null)
+ _ = sig.SendAsync(new SignalingMessage
+ {
+ Type = SignalingMessageType.Candidate,
+ Candidate = e.Candidate.Candidate,
+ SdpMid = e.Candidate.SdpMid,
+ SdpMLineIndex = e.Candidate.SdpMLineIndex
+ });
+ }
+
+ private void OnTrack(object? sender, RtcTrackEventArgs e)
+ {
+ // TODO: Attach e.Track to _remoteRenderer once VideoRenderer interface is expanded.
+ Dispatcher.Invoke(() => SetStatus($"Remote {e.Track.Kind} track received."));
+ }
+
+ private void OnConnectionStateChange(object? sender, EventArgs e) =>
+ Dispatcher.Invoke(() => SetStatus($"Connection: {_peerConnection?.ConnectionState}"));
+
+ private async Task HangUpAsync()
+ {
+ // Best-effort Bye — send before tearing down the channel.
+ if (_signaling != null)
+ {
+ try { await _signaling.SendAsync(new SignalingMessage { Type = SignalingMessageType.Bye }); }
+ catch { }
+ _signaling.Dispose();
+ _signaling = null;
+ }
+
+ _peerConnection?.Close();
+ _peerConnection = null;
+
+ _audioTrack?.Stop();
+ _videoTrack?.Stop();
+ _localStream?.Dispose();
+ _localStream = null;
+ _audioTrack = null;
+ _videoTrack = null;
+
+ ConnectBtn.IsEnabled = true;
+ HangUpBtn.IsEnabled = false;
+ MuteBtn.IsEnabled = false;
+ MuteBtn.IsChecked = false;
+ CameraBtn.IsEnabled = false;
+ CameraBtn.IsChecked = false;
+ SetStatus("Ready");
+ }
+
+ private void SetStatus(string message) =>
+ Dispatcher.Invoke(() => StatusText.Text = message);
+}
diff --git a/examples/BasicVideoChat/README.md b/examples/BasicVideoChat/README.md
new file mode 100644
index 0000000..57c0dc4
--- /dev/null
+++ b/examples/BasicVideoChat/README.md
@@ -0,0 +1,81 @@
+# BasicVideoChat
+
+A peer-to-peer audio/video call example using the **WebRtcNet** API.
+
+Two computers connect directly over TCP — no messaging server required. One peer acts
+as the **Host** (listens for an incoming connection) and the other is the **Guest**
+(dials by IP and port).
+
+---
+
+## Prerequisites
+
+- Windows 10 or later
+- .NET 10.0 **or** .NET Framework 4.8
+- A built copy of `WebRtcInterop` (requires WebRTC native libraries — see the
+ [repository README](../../README.md) for setup)
+
+> **Note:** Because several `WebRtcInterop` methods are not yet implemented,
+> this application will not run end-to-end today. It is intended as a working
+> specification of the API surface that still needs to be wired up in the
+> interop layer.
+
+---
+
+## How to run
+
+### Host side
+
+1. Launch the application.
+2. Leave the **Host** radio button selected.
+3. Set the **Port** (default: `7777`).
+4. Click **Connect** — the app waits for a guest.
+
+### Guest side
+
+1. Launch the application on a second machine.
+2. Select the **Guest** radio button.
+3. Enter the **IP address** of the Host and match the **Port**.
+4. Click **Connect**.
+
+---
+
+## ICE / STUN configuration
+
+The application uses Google's public STUN server by default:
+
+```
+stun:stun.l.google.com:19302
+```
+
+This is hardcoded in `MainWindow.xaml.cs` as `DefaultConfiguration`. For LAN-only
+use you can replace it with an empty `RtcConfiguration()`. For calls across NAT you
+may want to add a TURN server. See the
+[WebRTC getting-started guide](https://webrtc.org/getting-started/turn-server) for
+details on running your own TURN server.
+
+---
+
+## Features
+
+| Feature | Status |
+|---|---|
+| Audio + Video | Pending interop |
+| Mute audio toggle | Pending interop |
+| Camera off toggle | Pending interop |
+| Direct TCP signaling (no server) | Implemented |
+| ICE/STUN support | Implemented |
+
+---
+
+## Architecture notes
+
+- **Signaling**: Newline-delimited JSON over a single persistent TCP connection.
+ `TcpSignalingChannel` serialises/deserialises `SignalingMessage` objects
+ (`Offer`, `Answer`, `Candidate`, `Bye`).
+- **ICE candidate ordering**: The signaling read loop awaits the message handler
+ before reading the next message, so `SetRemoteDescription` is always guaranteed
+ to complete before any `AddIceCandidate` arrives.
+- **WpfVideoRenderer**: A prototype `VideoRenderer` implementation using
+ `WriteableBitmap`. Proper renderer assemblies for WPF, Windows Forms, and WinUI
+ are tracked in [issue #36](https://github.com/General-Fault/WebRtcNet/issues/36).
diff --git a/examples/BasicVideoChat/Signaling/SignalingMessage.cs b/examples/BasicVideoChat/Signaling/SignalingMessage.cs
new file mode 100644
index 0000000..4de7e0b
--- /dev/null
+++ b/examples/BasicVideoChat/Signaling/SignalingMessage.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace BasicVideoChat.Signaling;
+
+internal enum SignalingMessageType
+{
+ Offer,
+ Answer,
+ Candidate,
+ Bye
+}
+
+internal class SignalingMessage
+{
+ [JsonPropertyName("type")]
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public SignalingMessageType Type { get; set; }
+
+ [JsonPropertyName("sdp")]
+ public string? Sdp { get; set; }
+
+ [JsonPropertyName("candidate")]
+ public string? Candidate { get; set; }
+
+ [JsonPropertyName("sdpMid")]
+ public string? SdpMid { get; set; }
+
+ [JsonPropertyName("sdpMLineIndex")]
+ public ushort? SdpMLineIndex { get; set; }
+}
diff --git a/examples/BasicVideoChat/Signaling/TcpSignalingChannel.cs b/examples/BasicVideoChat/Signaling/TcpSignalingChannel.cs
new file mode 100644
index 0000000..7f3ecb8
--- /dev/null
+++ b/examples/BasicVideoChat/Signaling/TcpSignalingChannel.cs
@@ -0,0 +1,126 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BasicVideoChat.Signaling;
+
+///
+/// Minimal TCP signaling channel for peer-to-peer connection setup.
+///
+///
+/// One peer calls (Host) and the other calls
+/// (Guest). Messages are newline-delimited JSON.
+///
+/// The read loop awaits before processing the next message,
+/// ensuring that SetRemoteDescription always completes before any
+/// AddIceCandidate call arrives from the remote peer.
+///
+///
+internal sealed class TcpSignalingChannel : IDisposable
+{
+ private TcpListener? _listener;
+ private TcpClient? _client;
+ private StreamReader? _reader;
+ private StreamWriter? _writer;
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+ private CancellationTokenSource _cts = new();
+
+ ///
+ /// Invoked sequentially for each received message. The read loop awaits this
+ /// delegate before reading the next line.
+ ///
+ public Func? MessageHandler { get; set; }
+
+ /// Raised when the remote side closes the connection.
+ public event Action? Disconnected;
+
+ ///
+ /// Starts listening on and waits for a single guest connection.
+ /// Returns once the guest has connected.
+ ///
+ public async Task ListenAsync(int port)
+ {
+ _listener = new TcpListener(IPAddress.Any, port);
+ _listener.Start();
+ _client = await _listener.AcceptTcpClientAsync();
+ _listener.Stop();
+ AttachStreams();
+ _ = ReadLoopAsync(_cts.Token);
+ }
+
+ ///
+ /// Connects to a host at :.
+ ///
+ public async Task ConnectAsync(string host, int port)
+ {
+ _client = new TcpClient();
+ await _client.ConnectAsync(host, port);
+ AttachStreams();
+ _ = ReadLoopAsync(_cts.Token);
+ }
+
+ private void AttachStreams()
+ {
+ var stream = _client!.GetStream();
+ _reader = new StreamReader(stream, Encoding.UTF8);
+ _writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true };
+ }
+
+ ///
+ /// Sends as a newline-delimited JSON object.
+ /// Uses a write lock so it is safe to call concurrently from ICE callbacks.
+ ///
+ public async Task SendAsync(SignalingMessage message)
+ {
+ var json = JsonSerializer.Serialize(message);
+ await _writeLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ await _writer!.WriteLineAsync(json).ConfigureAwait(false);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ private async Task ReadLoopAsync(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var line = await _reader!.ReadLineAsync().ConfigureAwait(false);
+ if (line == null)
+ break;
+
+ var message = JsonSerializer.Deserialize(line);
+ if (message != null && MessageHandler != null)
+ await MessageHandler(message).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (IOException) { }
+ catch (ObjectDisposedException) { }
+ finally
+ {
+ Disconnected?.Invoke();
+ }
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ _writer?.Dispose();
+ _reader?.Dispose();
+ _client?.Dispose();
+ _listener?.Stop();
+ _writeLock.Dispose();
+ }
+}
diff --git a/examples/BasicVideoChat/WpfVideoRenderer.cs b/examples/BasicVideoChat/WpfVideoRenderer.cs
new file mode 100644
index 0000000..dc6c0f3
--- /dev/null
+++ b/examples/BasicVideoChat/WpfVideoRenderer.cs
@@ -0,0 +1,52 @@
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using WebRtcNet.Media;
+
+namespace BasicVideoChat;
+
+///
+/// WPF implementation of using a -backed
+/// control.
+///
+///
+///
+/// This is a prototype that lives in BasicVideoChat until a dedicated
+/// WebRtcNet.Wpf renderer assembly is created (see GitHub issue #36).
+///
+///
+/// Once the interface is expanded to include a frame-delivery
+/// method, frames should be rendered here via the pattern below. The expected frame format
+/// from the native WebRTC stack is I420 (YUV planar); convert to BGR24 before writing pixels.
+///
+///
+/// public void RenderFrame(byte[] bgrData, int width, int height)
+/// {
+/// _image.Dispatcher.BeginInvoke(() =>
+/// {
+/// if (_bitmap == null || _bitmap.PixelWidth != width || _bitmap.PixelHeight != height)
+/// {
+/// _bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgr24, null);
+/// _image.Source = _bitmap;
+/// }
+/// _bitmap.Lock();
+/// _bitmap.WritePixels(new Int32Rect(0, 0, width, height), bgrData, width * 3, 0);
+/// _bitmap.Unlock();
+/// });
+/// }
+///
+///
+public class WpfVideoRenderer : VideoRenderer
+{
+ private readonly Image _image;
+#pragma warning disable IDE0052 // field reserved for future use when VideoRenderer is expanded
+ private WriteableBitmap? _bitmap;
+#pragma warning restore IDE0052
+
+ /// Creates a renderer backed by the given WPF control.
+ /// The WPF control that will display video frames.
+ public WpfVideoRenderer(Image image)
+ {
+ _image = image;
+ }
+}