Skip to main content

QTI Neon

Minimal, game-agnostic, relay-based UDP multiplayer protocol library.

Client A ←──── UDP ────→ Relay ←──── UDP ────→ Host
Client B ←──── UDP ────→ Relay

Clients never communicate directly. The relay routes packets by destination ID in the packet header, keeping NAT traversal trivial and host addresses private. The host is just another participant — it has no special network position, only a special protocol role.

Features

  • Relay-mediated UDP with automatic NAT traversal
  • Connection handshake with host-assigned client IDs and session tokens
  • Token-based reconnection (5-minute window by default)
  • Opt-in reliable delivery with retransmit and duplicate detection
  • Per-source rate limiting (100 pps default)
  • Auto-ping keepalive
  • Optional DTLS 1.2/1.3 encryption — relay-terminated, transparent to game code
  • Zero game-specific logic in the relay or library

Implementations

Each language implementation lives in its own subdirectory at the repository root, alongside its generated documentation under docs/<language>/.

All implementations conform to the same protocol spec — a client or host written in any language is fully interoperable with a relay or peer written in any other.

See PROTOCOL.md for the wire format specification that all implementations share.

Documentation

See ARCHITECTURE.md for a full description of the relay topology, session lifecycle, and reconnect flow.

See PROTOCOL.md for the wire format specification.

See CONFIGURATION.md for the complete configuration reference.

API documentation for each implementation is generated from source and lives under docs/<language>/.

Quick Start

1. Run a relay

Java
NeonConfig cfg = NeonConfig.defaults(); // relay port 7777
NeonRelay relay = new NeonRelay("0.0.0.0", cfg);
Thread.ofVirtual().start(relay::startAndRun);
Python
import threading
from qti_neon import NeonRelay, NeonConfig

relay = NeonRelay("0.0.0.0", NeonConfig())
threading.Thread(target=relay.start_and_run, daemon=True).start()
TypeScript
import { NeonRelay } from 'qti-neon';

const relay = new NeonRelay('0.0.0.0'); // relay port 7777
await relay.start();
Godot
var relay := NeonRelay.new("0.0.0.0")  # relay port 7777
var thread := Thread.new()
thread.start(relay.start_and_run)
Go
cfg := qtineon.DefaultConfig() // relay port 7777
relay, _ := qtineon.NewRelay("0.0.0.0", cfg)
go relay.StartAndRun()

2. Start a host

Java
NeonHost host = new NeonHost(42, "relay.example.com:7777", cfg);
host.setClientConnectCallback((id, name, sid) ->
    System.out.println(name + " joined as " + (id & 0xFF)));
host.setUnhandledPacketCallback((type, from, payload) ->
    handleGamePacket(type, from));
Thread.ofVirtual().start(() -> host.startAndRun());
Python
import threading
from qti_neon import NeonHost

host = NeonHost(session_id=42, relay_address="relay.example.com:7777")
host.set_client_connect_callback(lambda cid, name, sid: on_client_join(cid, name))
host.set_unhandled_packet_callback(lambda ptype, sender, payload: handle_game_packet(ptype, sender))
threading.Thread(target=host.start_and_run, daemon=True).start()
TypeScript
import { NeonHost } from 'qti-neon';

const host = new NeonHost(42, 'relay.example.com:7777');
host.setClientConnectCallback((id, name, sid) => onClientJoin(id, name));
host.setUnhandledPacketCallback((type, from, payload) => handleGamePacket(type, from));
await host.start();
Godot
var host := NeonHost.new(42, "relay.example.com:7777")
host.set_client_connect_callback(func(id, name, sid): on_client_join(id, name))
host.set_unhandled_packet_callback(func(type, from, payload): handle_game_packet(type, from))
var thread := Thread.new()
thread.start(host.start_and_run)
Go
host, _ := qtineon.NewHost(42, "relay.example.com:7777", cfg)
host.SetClientConnectCallback(func(id uint8, name string, sid int32) {
    fmt.Println(name, "joined as", id)
})
host.SetUnhandledPacketCallback(func(ptype, from uint8, payload []byte) {
    handleGamePacket(ptype, from)
})
go host.StartAndRun()

3. Connect a client

Java
NeonClient client = new NeonClient("player1", cfg);
client.setSessionConfigCallback(sc ->
    System.out.println("Tick rate: " + sc.tickRate()));
client.setUnhandledPacketCallback((type, from, payload) ->
    handleGamePacket(type, from));

if (client.connect(42, "relay.example.com:7777")) {
    Thread.ofVirtual().start(client::run);
}
Python
import threading
from qti_neon import NeonClient

client = NeonClient("player1")
client.set_session_config_callback(lambda sc: on_session_config(sc))
client.set_unhandled_packet_callback(lambda ptype, sender, payload: handle_game_packet(ptype, sender))

if client.connect(session_id=42, relay_address="relay.example.com:7777"):
    threading.Thread(target=client.run, daemon=True).start()
TypeScript
import { NeonClient } from 'qti-neon';

const client = new NeonClient('player1');
client.setSessionConfigCallback(sc => onSessionConfig(sc));
client.setUnhandledPacketCallback((type, from, payload) => handleGamePacket(type, from));

await client.connect(42, 'relay.example.com:7777');
Godot
var client := NeonClient.new("player1")
client.set_unhandled_packet_callback(func(type, from, payload): handle_game_packet(type, from))

if client.connect(42, "relay.example.com:7777"):
    var thread := Thread.new()
    thread.start(client.run)
Go
client, _ := qtineon.NewClient("player1", cfg)
client.SetSessionConfigCallback(func(sc qtineon.SessionConfig) {
    fmt.Println("Tick rate:", sc.TickRate)
})
client.SetUnhandledPacketCallback(func(ptype, from uint8, payload []byte) {
    handleGamePacket(ptype, from)
})

if client.Connect(42, "relay.example.com:7777") {
    go client.Run()
}

4. Send a packet

Java
byte[] data = encodePosition(x, y, z);
client.sendPacket(data, PACKET_POSITION, (byte) 0); // 0 = broadcast
Python
data = encode_position(x, y, z)
client.send_packet(data, PACKET_POSITION, dest_id=0)  # 0 = broadcast
TypeScript
const data = encodePosition(x, y, z);
client.sendPacket(data, PACKET_POSITION, 0); // 0 = broadcast
Godot
var data := encode_position(x, y, z)
client.send_packet(data, PACKET_POSITION, 0)  # 0 = broadcast
Go
data := encodePosition(x, y, z)
client.SendPacket(data, PacketPosition, 0) // 0 = broadcast

5. Reconnect after drop

Java
client.stop(); // or socket dies ungracefully
// ...
boolean ok = client.reconnect(); // uses stored session token
Python
client.stop()  # or socket dies ungracefully
# ...
ok = client.reconnect()  # uses stored session token
TypeScript
client.stop(); // or socket dies ungracefully
// ...
const ok = await client.reconnect(); // uses stored session token
Godot
client.stop()  # or socket dies ungracefully
# ...
var ok := client.reconnect()  # uses stored session token
Go
client.Stop() // or socket dies ungracefully
// ...
ok := client.Reconnect(5) // uses stored session token, up to 5 attempts

DTLS Encryption

DTLS is opt-in — the relay, host, and client handle the handshake automatically before any Neon packets are exchanged.

Java
// Relay — load a PKCS12 keystore with the relay's certificate
KeyStore ks = KeyStore.getInstance("PKCS12");
try (var is = new FileInputStream("relay.p12")) {
    ks.load(is, "password".toCharArray());
}
SSLContext relayCtx = DtlsConfig.fromKeyStore(ks, "password".toCharArray());

NeonConfig relayCfg = NeonConfig.builder()
    .sslContext(relayCtx)
    .build();

// Host / Client — trust the relay certificate (or use a proper TrustManager)
SSLContext clientCtx = DtlsConfig.insecureTrustAll(); // dev only
NeonConfig clientCfg = NeonConfig.builder()
    .sslContext(clientCtx)
    .build();
Python
from qti_neon import DtlsConfig, NeonConfig, NeonRelay, NeonClient

# Relay — load certificate and private key
relay_cfg = NeonConfig(dtls_config=DtlsConfig.from_key_store("relay.crt", "relay.key"))
relay = NeonRelay("0.0.0.0", relay_cfg)

# Host / Client — trust the relay certificate (or supply a proper trust store)
client_cfg = NeonConfig(dtls_config=DtlsConfig.insecure_trust_all())  # dev only
client = NeonClient("player1", client_cfg)
TypeScript
import { DtlsConfig, NeonConfig, NeonRelay, NeonClient } from 'qti-neon';

// Relay — load certificate and private key
const relayCfg = new NeonConfig({ dtlsConfig: DtlsConfig.fromKeyStore('relay.crt', 'relay.key') });
const relay = new NeonRelay('0.0.0.0', relayCfg);

// Host / Client — trust the relay certificate (or supply a proper trust store)
const clientCfg = new NeonConfig({ dtlsConfig: DtlsConfig.insecureTrustAll() }); // dev only
const client = new NeonClient('player1', clientCfg);
Godot
# Relay — load certificate and private key (PEM files)
var relay_cfg := NeonConfig.new({dtls_config = DtlsConfig.from_key_store("relay.crt", "relay.key")})
var relay := NeonRelay.new("0.0.0.0", relay_cfg)

# Host / Client — trust the relay certificate (or use insecure for dev)
var client_cfg := NeonConfig.new({dtls_config = DtlsConfig.insecure_trust_all()})  # dev only
var client := NeonClient.new("player1", client_cfg)
Go
// Relay — load a PEM certificate and private key
relayCfg := qtineon.DefaultConfig()
relayCfg.DTLS, _ = qtineon.FromKeyStore("relay.pem", "relay-key.pem")
relay, _ := qtineon.NewRelay("0.0.0.0", relayCfg)

// Host / Client — trust the relay certificate (or supply a proper trust store)
clientCfg := qtineon.DefaultConfig()
clientCfg.DTLS = qtineon.InsecureTrustAll() // dev only
client, _ := qtineon.NewClient("player1", clientCfg)

insecure_trust_all() / insecureTrustAll() / InsecureTrustAll() is for development and testing only — it accepts any certificate. For production, supply a trust manager that pins the relay's certificate.

When DTLS is not configured (the default), packets are sent in plaintext.

Installation

Java (Maven)
<dependency>
    <groupId>com.quietterminal</groupId>
    <artifactId>qti-neon</artifactId>
    <version>1.0.0</version>
</dependency>
Python (pip)
pip install qti-neon

With DTLS support:

pip install "qti-neon[dtls]"
TypeScript (npm)
npm install qti-neon

With DTLS support:

npm install koffi
Godot (Asset Library / manual)

Copy (or symlink) godot/addons/qti_neon/ into your project's addons/ directory, then enable the plugin in Project → Project Settings → Plugins.

No package manager step is required — the implementation is pure GDScript with no external dependencies.

Go (go get)
go get github.com/Quiet-Terminal-Interactive/QTINeon/go

DTLS support (github.com/pion/dtls/v2) is a direct dependency and requires no extra installation step.

Building

Java
mvn verify

Tests that open UDP sockets (most of them) must run outside a sandbox:

mvn test

Generate docs:

mvn javadoc:javadoc
# output: target/reports/apidocs/index.html
Python
cd python
pip install -e ".[dev]"
pytest

Generate docs:

pdoc src/qti_neon --output-dir ../docs/python
# output: ../docs/python/qti_neon.html
TypeScript
cd js-ts
npm install
npm test

Generate docs:

npm run docs
# output: ../docs/ts/index.html
Godot

No build step — the implementation is pure GDScript. To verify the compliance scripts parse correctly:

cd godot
godot --headless --check-only --script compliance/neon_host_runner.gd

To run the cross-language compliance tests (requires Java and Node.js):

python3 test_compliance.py
Go
cd go
go build ./...
go vet ./...

Tests open real UDP sockets on loopback:

go test ./...

Generate docs:

gomarkdoc ./... --output ../docs/go/README.md
# output: ../docs/go/README.md

Client IDs

ID Role
0 Broadcast / unassigned
1 Host
2–254 Connected clients
255 Reserved

License

MIT — see LICENSE.

Metadata

Release files for qti-neon 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for qti-neon 1.1.0
File Size Uploaded Provenance
qti_neon-1.1.0.tar.gz 41.1 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for qti-neon 1.1.0
File Interpreter ABI Platform
qti_neon-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 79.4 kB

Release files / qti_neon-1.1.0.tar.gz

Download URL qti_neon-1.1.0.tar.gz
Size 41.1 kB
Tags Source
SHA-256 checksum
How to use checksums
ec051be025fe9268bea9e8ba06133dd16659261793ca9926d5669fef16f91f8c
BLAKE2b-256 checksum
How to use checksums
044e19c43729a7a3e4bbdfdc125e7d0b419f0c9c2379b2e837c34f39245f683f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Release files / qti_neon-1.1.0-py3-none-any.whl

Download URL qti_neon-1.1.0-py3-none-any.whl
Size 38.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
43dae0f08646146a74a5fe7996d3da03a51b29f9f9e850fa8b24f110e0a7c6a3
BLAKE2b-256 checksum
How to use checksums
b9e98228ddacf722cece432e5ed4d6e8f9210cfc690476b669a9aaecae8eb06c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Security for qti-neon 1.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page