How Multi-Hop Message Relay Works in Bitchat’s BLE Mesh Using TTL-Based Routing

Bitchat implements multi-hop message relay by decrementing a TTL (Time-to-Live) field at every hop, with relay decisions made by the RelayController that applies topology-aware caps and random jitter to prevent flooding and collisions.

Bitchat is an open-source Bluetooth Low Energy (BLE) mesh messaging application that enables decentralized communication without internet infrastructure. The repository implements a deterministic multi-hop message relay system using TTL-based routing to propagate packets across sparse or dense network topologies while preventing infinite forwarding loops.

Packet Reception and Context Extraction

When a BLE packet arrives at a node, the BLEService delegates processing to the BLEReceivePipeline. This pipeline parses the raw advertisement data and constructs a BLEReceivedPacketContext containing the sender identifier, message ID, and current TTL value.

In BLEReceivePipeline.swift (lines 13‑25), the static method relayDecision forwards this metadata to the routing engine. This initial extraction phase isolates the relay logic from transport-specific BLE handling, ensuring the TTL and peer information are available for routing calculations.

The TTL-Based Routing Decision Engine

The core routing policy resides in RelayController.swift (lines 12‑95). The decide function implements the TTL-based routing logic that determines whether a packet should be forwarded and with what remaining hop budget.

The decision flow follows three strict rules:

  1. Initial Capping: The incoming TTL is first capped by the global default TransportConfig.messageTTLDefault.
  2. Topology-Aware Limits: Different traffic types receive specific TTL constraints before decrementing.
  3. Termination Check: If the resulting TTL is ≤ 1, the packet is consumed locally and not relayed.

When the node decides to relay, the function returns a RelayDecision struct containing shouldRelay (boolean), the decremented newTTL (integer), and a delayMs value (integer) calculated from random jitter.

Directed Traffic Handling

For directed traffic such as handshake messages, encrypted direct messages, and ping packets, the relay policy is aggressive. These packets are always forwarded provided the TTL allows, with the value decremented by exactly one. A small random jitter is added to the transmission delay to prevent synchronized collisions when multiple nodes relay the same packet simultaneously.

Fragmented and Voice Traffic

Fragmented packets and voice frames use graph-density-aware caps defined by bleFragmentRelayTtlCap (sparse networks) or bleFragmentRelayTtlCapDense (dense networks). This prevents high-bandwidth traffic from saturating well-connected mesh regions while still allowing propagation across sparse topologies. The cap is applied before the TTL decrement.

Broadcast Traffic

Broadcast packets—including node announcements and board posts—receive a calculated TTL limit based on the local node's perception of network degree. Sparse topologies retain the full hop budget for long-range propagation, while dense networks are clamped to reduce unnecessary fan-out and battery drain.

Scheduling and Broadcasting the Relay

Once the RelayController approves forwarding, BLEService.scheduleRelayIfNeeded (lines 76‑95 in BLEService.swift) executes the transmission. This method creates a deep copy of the original BitchatPacket, overwrites its ttl field with decision.newTTL, and enqueues a broadcast work item.

The scheduling system respects the delayMs value from the routing decision, transmitting the packet after the jitter delay expires. This guarantees that every relay hop reduces the TTL exactly once, providing a deterministic hop count that downstream receivers can reconstruct.

Hop Count Diagnostics with Origin TTL

Bitchat leverages the TTL mechanism for network diagnostics through the MeshPingPayload structure defined in MeshPingPayload.swift (lines 15‑55). Each ping packet embeds an originTTL field set to the initial TTL value at the source.

Receivers calculate the exact traversal distance using the static method hopCount(originTTL:receivedTTL:), which computes originTTL – receivedTTL + 1. This metric powers the mesh ping round-trip time (RTT) measurement, allowing nodes to map network topology and link quality without additional probe traffic.

// Construct a packet with default TTL for multi-hop propagation
let pkt = BitchatPacket(
    type: MessageType.announce.rawValue,
    ttl: TransportConfig.messageTTLDefault,
    senderID: myPeerID.rawData,
    recipientID: nil,
    payload: somePayloadData,
    timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
)

// Transmit via BLE service; relay logic triggers automatically
bleService.sendPacket(pkt)

// Calculate hops from a received mesh ping
func handleMeshPing(_ pkt: BitchatPacket, fromLink link: PeerID) {
    guard let ping = try? MeshPingPayload(data: pkt.payload) else { return }
    let hops = MeshPingPayload.hopCount(
        originTTL: ping.originTTL,
        receivedTTL: pkt.ttl
    )
    print("Ping traversed \(hops ?? 0) hop(s)")
}

Summary

  • TTL Decrement Guarantees Termination: Every relay hop in BLEService decrements the TTL by one, ensuring packets expire after a fixed number of hops and preventing infinite loops.
  • Topology-Aware Capping: The RelayController applies different TTL limits for directed, fragmented, and broadcast traffic based on network density, optimizing for both sparse long-range and dense short-range scenarios.
  • Jitter Prevents Collisions: Random delay injection in RelayDecision desynchronizes simultaneous relays from multiple neighbors.
  • Diagnostic Transparency: MeshPingPayload carries the origin TTL, enabling precise hop-count calculation without protocol overhead.

Frequently Asked Questions

How does Bitchat prevent infinite packet loops in the BLE mesh?

The TTL-based routing mechanism decrements the Time-to-Live field at every hop. When RelayController.decide calculates a newTTL ≤ 1, the packet is consumed locally and never scheduled for broadcast. This hard limit guarantees that every packet dies after at most TransportConfig.messageTTLDefault hops, regardless of network cycles or redundant paths.

What is the difference between how directed messages and broadcast messages are relayed?

Directed messages (handshakes, encrypted DMs, pings) are relayed with the TTL decremented by one and minimal jitter, prioritizing delivery speed. Broadcast messages (announces, board posts) receive a dynamic TTL cap based on local network density before decrementing; dense networks see reduced hop limits to prevent flooding, while sparse networks allow full propagation budgets.

How can a node determine how many hops a message traveled?

Nodes call MeshPingPayload.hopCount(originTTL:receivedTTL:) which calculates originTTL – receivedTTL + 1. The originTTL is embedded in the ping payload at the source, while receivedTTL is the value in the packet header when it arrives. This subtraction yields the exact number of relay hops traversed.

Where is the relay delay jitter calculated in the source code?

The jitter delay is calculated inside RelayController.swift within the decide function (around lines 12‑95). The resulting delayMs value is packaged into the RelayDecision struct and consumed by BLEService.scheduleRelayIfNeeded in BLEService.swift, which schedules the actual broadcast after the specified millisecond delay.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →