Minimal, game-agnostic, relay-based UDP multiplayer protocol library
Project description
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()
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) ->
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: handle_game_packet(ptype, sender))
threading.Thread(target=host.start_and_run, daemon=True).start()
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) ->
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: 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()
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
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
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)
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]"
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
Client IDs
| ID | Role |
|---|---|
0 |
Broadcast / unassigned |
1 |
Host |
2–254 |
Connected clients |
255 |
Reserved |
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file qti_neon-1.0.0.tar.gz.
File metadata
- Download URL: qti_neon-1.0.0.tar.gz
- Upload date:
- Size: 39.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c3dae36d1a301da027ff660595f6b04d80c5925d5aa11775c135bc8482cbf24
|
|
| MD5 |
e89357635ed57801a6e342864ad660d3
|
|
| BLAKE2b-256 |
d083210f9332004fc679e33732cc170aea3e60992643c5c9106e4ff1604345ce
|
File details
Details for the file qti_neon-1.0.0-py3-none-any.whl.
File metadata
- Download URL: qti_neon-1.0.0-py3-none-any.whl
- Upload date:
- Size: 36.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fc52948a009c4a978e7a1f329df8bb65e6e1cc77483430b68d80f63d1c3d2f43
|
|
| MD5 |
5ab0ddca556521abb86974a9346212c7
|
|
| BLAKE2b-256 |
ee44b86094481ef9f9f7a3e21792711c4a2a2929ccddc0ee42f044b75a0dec30
|