Networking¶
Multiplayer v1 (issue #2): host a game or join one, on a LAN or on one PC. You see the other players walk, jump, vault and climb, you push the same crates, and you see their shots. v2 (issue #28, first part): breakables break into the same pieces for everyone, what the host's scripts spawn appears for everyone, and the host refuses moves through walls and flights. The plan behind it, and what comes after it, is in ACTION_PLAN.md "Networking (2.1)".
Try it¶
Two kke_demo windows on one PC:
KKE_NET=host KKE_NET_NAME=Kees ./kke_demo
KKE_NET=join:127.0.0.1 KKE_NET_NAME=Guest ./kke_demo # a second terminal
Or press F1 in kke_demo and use the Network panel: Host, Join by
address, or Search LAN (every game on the network and on this PC, with its
player count; click Join). The panel shows each player's round trip, loss
and your bandwidth, and has sliders to simulate a bad connection.
| Variable | Meaning |
|---|---|
KKE_NET |
host, host:PORT, join:ADDRESS, join:ADDRESS:PORT or join:CODE[@RELAY] |
KKE_NET_RELAY |
host[:port] of a relay: hosting gets a join code, and codes typed without @relay are asked there (docs/SERVER_HOSTING.md "Join codes") |
KKE_NET_NAME |
your name in the game and in the LAN list |
KKE_NET_LAG |
extra one-way delay in ms on everything this game sends |
KKE_NET_JITTER |
± random ms per packet (reorders unreliable packets) |
KKE_NET_LOSS |
percent of unreliable packets dropped |
KKE_NET_REPLAY |
1: host with input replay (competitive; see "Input replay") |
A host takes UDP port 27960, or the next free one up to 27975, so several
hosts can run on one PC and the LAN search still finds all of them. Allow
that range in the firewall to play across machines, or use a join code:
with a relay set, the host's panel shows a code like K7M-Q2P@relay.example.org
and friends type it in the Address box instead of an address; nobody
forwards a port (docs/SERVER_HOSTING.md "Join codes").
Encryption¶
Every connection is encrypted, on a LAN too, with nothing to set up
(kke/net/SecureTransport.h, issue #44). The client and the server each
make a throwaway X25519 key; the server adds its long-term key and proves
it holds it; both derive one key per direction with BLAKE2b; every packet
is then sealed with XChaCha20-Poly1305 (Monocypher, already used for
kke_seal) and a counter as the nonce. So:
- nobody on the path (café Wi-Fi, a relay, an ISP) can read the password, chat, voice or moves, nor change or replay a packet: a packet that fails its check is dropped, and a peer sending more than 20 of those is dropped too;
- a joiner who knows the server's key (a join code: the relay hands it over) refuses any other, so nobody in the middle can pose as the server; typing a plain address has no key to compare with (encrypted all the same, like a first visit over https without a certificate);
- keys are forward secret: a server key stolen later doesn't open old recordings.
It costs 25 bytes per packet and a few microseconds of CPU. kke_server
keeps its key in saveDir/server.key (owner-only) and prints its
fingerprint at start; a game hosting makes a new key each time. The
handshake has its own version, so a game from before encryption can't
join a new one (and is told nothing it could use).
How it works¶
game (ShowcaseModule) NetModule NetServer / NetClient ITransport
--------------------- --------- --------------------- ----------
setLocalPlayer(state) ---> host: + bodies --------> snapshots, events ---> ENet (UDP)
remotePlayers() <--- capsules, bodies <------- interpolation <--- Loopback (tests)
sendEvent / onEvent movement checks ConditionedTransport
- Transport (
kke/net/Transport.h): an interface with a reliable ordered channel and an unreliable sequenced one.EnetTransportis the real one (ENet, MIT);LoopbackTransportruns any number of peers in one process on a simulated network with a manual clock (the tests);ConditionedTransportadds lag, jitter, loss and duplicates to any of them (the panel sliders). - Serialization (
kke/net/BitStream.h,Protocol.h): Glenn Fiedler's pattern, one templatedserializefunction per message that both writes and reads, so the two can't drift apart. Values are bit-packed to their range and resolution: a position is 3 x ~23 bits at 1 mm, a rotation is "smallest three" in 35 bits. Every decoder rejects short, long and out-of-range packets; a fuzz test throws random bytes at all of them. - Sessions (
kke/net/NetSession.h): one server (the host's game, or a process of its own), any number of clients. - Players: each client moves its own player at once (no input delay)
and sends its state 30 times a second. The server checks each move
against speed limits (
MovementLimits) and sends aCorrectionwhen one breaks them, then shares all players in its snapshots. - Bodies: the server's physics is the truth. Each snapshot is one UDP packet (1100 bytes); which bodies go in is decided per client by a priority accumulator: moving bodies gain priority faster than sleeping ones, and every body is sent eventually.
- Interpolation: everything that isn't yours is drawn 100 ms in the
past, between two received states, so it moves smoothly through
jitter and a lost packet or two. A
ClockOffsetmaps the sender's clock onto ours. - Events: reliable, game-defined messages (a shot, a push). The server receives a client's event and decides whether to apply and relay it.
- Spawned objects: things the host makes while playing (a server
script's crate or breakable) are
Spawnmessages: an id, a kind and the builder's own description (up to 256 bytes). Each client builds its copy; late joiners get the ones still there (a thrown ball is "transient" and isn't thrown again for them). Bodies then travel in snapshots like the level's;Despawnremoves them. - Breaks:
Breakmessages carry which borders of a breakable broke on the host (below). - Robustness: protocol version (now 6) and game id checked at join,
then the server's password if it has one (compared in constant time;
KKE_NET_PASSWORD, or the panel's Password field) and its access list (NetServer::admit: bans, allow list), a full server says so, silent peers time out, clients sending bad packets, server-only messages or too many events are dropped. - Dedicated servers:
NetConfig::dedicatedgives all slots to clients (no host player).kke_server, its roles, the directory and Docker are in docs/SERVER_HOSTING.md. A server's text for players (MOTD,say) is the eventkEventServerMessage; NetModule logs it and shows it in the panel. - NetModule (
kke/modules/NetModule.h): the engine module that ties it to a game. The game registers its replicated Jolt bodies in the same order on every machine (replicateBody), gives its player's state each frame, and drawsremotePlayers(). Other players get a kinematic capsule in the local Jolt world, so they block you and, on the host, push crates.
Why the client simulates the crates too¶
The first version made a client's crates kinematic copies of the host's. They looked right but could not be pushed: your character is blocked by the local copy before your capsule on the host ever touches the real one. Now a client simulates its crates like the host does, so walking into one pushes it at once, and each frame steers every crate toward the host's state (carried forward by its velocity to about "now"):
- velocity blend toward the host's velocity plus a position-error term (6/s, blended at 12/s), so contacts stay stable;
- a crate the host has asleep and that is (nearly) still here slides the last centimetres into place, because ground friction would otherwise eat the small correcting velocity and leave it a few centimetres off; not while you're walking into it, which is you starting a push;
- more than 2 m off (a reset, a missed tumble) it jumps.
Measured with two kke_demo instances: a guest pushes a crate 1.8 m, and
host and guest agree on where it stops to within 1 cm; with 80 ms lag each
way, 20 ms jitter and 5% loss both ends still agree within 1 cm. Under lag
a push feels heavier (the host's copy pulls back until it catches up).
Breakables¶
FEMFX isn't deterministic across machines, so simulating each copy and
hoping isn't enough: one player's shot would crack the glass on one
screen and leave it whole on another. What is the same everywhere is
how a breakable is cut: its pieces are baked from a fracture seed
(kke::bakeFracture), and it breaks KKE's own way, border by border
(kke::BreakGraph, PhysicsModule "Breakable"). So:
- The host's breakables break from their stresses as always. Each frame
NetModule asks each replicated one how many borders are broken
(a cheap count) and, when that changes, sends the new borders as
(piece, piece)pairs with the breakable's seed. - A client's copies are followers: they never break on their own, only along the borders the host sends. Same pieces, same borders, same result: the pieces the client sees are the host's. Where the debris then flies is each machine's own (cosmetic, like before).
- A client whose copy was baked with another seed (another object in that slot, a changed world seed) logs an error and leaves it alone instead of breaking it wrongly.
- Late joiners get every border broken so far, and breaks for a breakable they don't have yet wait until it's there.
- Leaving a game makes them break on their own again.
Games call replicateBreakable(handle) for the level's breakables, in the
same order on every machine (kke_demo does it for the breaking yard);
script breakables are handled for you. Measured with two kke_demo
windows: the host's script drops a ball on a glass pane, the host's pane
breaks along 80 borders into 35 pieces, the guest's along the same 80 into
the same 35; the yard's stone wall, 6 borders and 2 pieces on both.
A break reaches a client about half a round trip after it happened (plus the reliable channel's resend if a packet was lost), so on a client a pane cracks a moment after the ball hits it.
Movement checks¶
The server always checked moves against speed limits. With the level in its own physics world, the host now also refuses (and sends the player back to where it last was legitimately) a move that
- goes through a wall: the path from the last accepted position to the new one, 0.5 m above the feet, crosses static level geometry. The straight line or "up, over, down" must be clear, so stairs, vaults and ledge climbs pass; crates and other players don't count;
- flies: with nothing to stand on (0.6 m below) or hold (a wall or ledge beside you, for climbs, hangs and wall runs), it has either risen more than 3 m, or it has been in the air over 1.5 s without falling as (a third of) gravity would make it. Standing on a crate counts; your own stand-in capsule doesn't.
kke/net/WorldMoveCheck.h has the rules and MoveCheckSettings the
numbers (NetModule::moveCheckSettings; a glider or jetpack game turns
the fall rule off with minFallGravity = 0). The Network panel shows the
refusals and has a switch; the log gets one line per player per 5 s.
Any game can add its own rule through NetServer::checkMove.
Input replay¶
Two models for players, chosen by the host:
- Owner-predicted (the default; co-op, sandbox, most games): your game moves your player and sends where it is; the host checks each move (above). Nothing to set up, nothing ever snaps back for an honest player, and it catches the blatant cheats. Small ones (running a little fast inside the slack) get through.
- Input replay (competitive; tick Input replay next to Host, or
KKE_NET_REPLAY=1): your game sends inputs (move direction, sprint, walk, crouch, jump, where you look) 60 times a second and the host runs everyone's movement itself. Where a player is, is whatever the host's ownkke::Locomotionmade of their inputs: speed hacks, fly hacks, teleports and walking through walls have nothing to send. This is the model of competitive shooters (Valve's Source, Overwatch, Rocket League).
Nobody wants to wait for the host before their player moves, so with
input replay your game still moves your player the moment you press
(prediction). It keeps each input and the state after it; when the host's
answer for an input disagrees (by more than 5 cm, or in another movement
state: a vault the host didn't do), it goes back to that input, takes
the host's position and plays every input since again (rewind and
replay). With an honest client and the same level both sides agree and
nothing moves; when they don't, the jump is hidden by fading a drawing
offset (playerDrawOffset()).
What makes it work (kke/net/InputReplay.h, LocomotionReplay.h):
- Exactly the same numbers on both sides. Inputs are quantized before
the client steps them, to what the wire carries (
quantize()), and both sides step fixed 1/60 s ticks whatever the frame rate. - Rewindable movement.
RigidWorld::characterState()saves a character completely (Jolt's own state of theCharacterVirtual: position, velocity, contacts, ground; its input, height and clock) andsetCharacterState()puts it back; aLocomotionis plain data, so a copy is its whole state (restore()). A replayed character is stepped on its own (stepCharacter(),setCharacterManual()), one input at a time, so it can be replayed without stepping the whole world. - The host waits rather than guesses (
InputQueue). Each host tick a player may play one input. If theirs hasn't come (a slow frame, a jittery link) they stand still a moment on the host and the ticks are made up when the inputs arrive together, so the host runs exactly the inputs the client predicted with: a bad connection costs latency, not corrections. More than one input per tick on average can't be had: a client sending faster only fills the queue, whose oldest inputs are skipped (a jump in them still happens). - Loss. Every input packet carries the newest 16 unacknowledged inputs,
so a lost packet's inputs arrive with the next. The host answers with
InputAck(the last input it played and the state after it) with each snapshot.
In a game: NetModule::setPlayer(&locomotion, character) once, then each
frame if (!net.stepPlayer(input, dt)) locomotion.update(...) instead of
updating it yourself: offline, hosting or with owner prediction it
returns false and nothing changes. The host creates a character and a
Locomotion for each joining client (at replaySpawn(id)) and runs
them. The showcase does all of this (ShowcaseModule::stepNetPlayer).
For now, with input replay: players don't block each other (the host's characters don't collide with one another, so a client's prediction doesn't bump into stand-ins either); a teleport or respawn is the host's to make (one made by the client alone is put back); crates and other bodies near you are the host's, so pushing one is predicted only as far as your copy of it goes. Everything else (events, voice, spawns, breaks) works as with owner prediction.
Several players on one screen¶
Split screen online, like Halo: two (or more) people on one couch join someone else's game from one machine, and the host can have friends on its couch too. Up to 8 players share one connection (slot 0 is the game's own player, slots 1 to 7 are guests); each is a player of its own for everyone else, with its own id, name, state and movement checks.
net.addLocalPlayer(1, "Sam"); // before or after host() / join()
// each frame, like setLocalPlayer(state) for the first one:
net.setLocalPlayer(1, samState);
net.localPlayerId(1); // its id in the game (0 until the host gave one)
net.isLocalPlayer(id); // one of this screen's? (don't draw it as a remote)
net.removeLocalPlayer(1); // Sam puts the controller down
- On the wire (protocol 6): a client's
Guestmessage asks for a player in a slot, the host'sGuestAckgives its id or says why not (the game is full, a ban, input replay).PlayerStateandCorrectioncarry the slot. A host's own guests need no messages (NetServer::addLocalGuest). - Guests count toward
maxPlayers, so a full game turns the next guest or joiner away; the host's LAN and directory entries count them too. - Nobody is sent their own screen's players in snapshots; the fog of war shows a player to a screen when any of its players can see it.
- Guests share their connection's events (the event's
fromPlayeris the connection's first player) and voice (one microphone per machine). - A guest leaving costs only it; the connection going takes its guests with it; kicking a guest tells its owner and keeps the connection.
NetPlayerState::extra(up to 24 bytes, the game's own) travels with every state: Climb Race puts where the hands and feet are in it.- Not with input replay yet: there each connection plays one player, and a guest is refused with the reason.
In kke_demo¶
- Players: the local character model, with its own animator per remote player, driven by their state (locomotion state, speed, traversal progress, crouch, fall height). Without the animation library installed they're boxes.
- Crates and the moving platform are the host's. R on a client asks the host to reset them.
- Shots: everyone sees every FEMFX ball. The breaking yard's glass, plank and stone wall break on the host and in the same pieces for everyone (Breakables above).
- Scripts: what
sv_*.luaspawns shows up for everyone (docs/SCRIPTING.md "Multiplayer"). - Internet servers: the Network panel (F1) lists the public servers a
directory knows for this game (docs/SERVER_HOSTING.md "Servers helping
each other"):
KKE_DIRECTORIES=host:portfills it at start, Refresh asks again, Join connects (with the Password field's password).
Tests¶
tests/test_net.cpp (19 tests, all in CI):
- BitStream: round trips at the edges of every range, quaternions, strings, reading past the end.
- Protocol: every message round trips; truncated, padded, wrong-type and random packets are rejected (fuzzing).
- Loopback network: connect, both channels, disconnect; loss, delay and reordering only where UDP would (reliable stays complete and in order).
- Sessions on the loopback network: join and see each other and the host, bodies by priority within one packet, events and relays, a wrong version, a wrong game and a full server turned away, impossible moves corrected while a real teleport is allowed, a peer sending garbage kicked, leaving seen by everyone, other players still smooth at 80 ms ±25 ms lag with 10% loss.
- Real UDP: two ENet hosts on one PC, LAN discovery finds the host, a client joins and both see each other.
v2 adds (in the same file): Spawn, Despawn and Break round trips
and fuzzing, script spawn descriptions (damaged or out-of-range ones
refused), spawns reaching clients and only the persistent ones reaching
late joiners, breaks sent once and replayed to late joiners (2500 borders
split over several messages), a client sending server-only messages
kicked, and the game's move check refusing and correcting.
Several players on one screen adds: a client with two guests and a host
with one, where everyone sees everyone else (the extra bytes too), nobody
gets their own screen's players, a guest leaving and a connection going;
guests filling a server so a late joiner is turned away, a kicked guest's
owner told and still connected; guests refused under input replay; a
guest's impossible move corrected by its slot, not the first player's.
The fuzz test covers Guest, GuestAck and states with extra bytes.
tests/test_break_graph.cpp: a follower given the host's borders ends up
with the host's pieces; borders that don't exist can't be broken.
tests/test_move_check.cpp: walking, jumping and a 30 m fall pass; through
a wall is refused; a vault, a ledge climb and five seconds hanging pass;
rising or hovering in the open is flying; a crate holds you up, your own
capsule doesn't; the honest fall after a refusal passes.
tests/test_rigid_world.cpp: bodies switch between dynamic and kinematic
(kinematic ones hold still and push, dynamic again they fall).
tests/test_input_replay.cpp (input replay): the host plays each input
once and in order, waits for a late one and makes the ticks up, skips a
lost one after a few ticks, and a client with a fast clock or one that
goes quiet and bursts gets no extra distance; a press in a skipped input
still happens; a fair client is never corrected, a push only the server
knows about is rewound and replayed to the server's exact position, as
is another movement state in the same place; inputs round trip bit for
bit as the client stepped them; a Jolt character and its Locomotion put
back mid-run move exactly the same again through a vault; and over a
simulated network with lag, jitter and loss a Locomotion player vaults
the same on both sides, a client with a wall missing from its level is
stopped by the host's, and a cheat's fast clock buys nothing.
Not yet¶
- Rollback for fighting games, lockstep for RTS (ACTION_PLAN.md); with input replay: player-vs-player collision, host-side respawns, and lag-compensated hit checks (rewinding the other players to what the shooter saw).
- Remembering a server's key per address (so a plain-address join can warn when it changes); a relay per region picked by ping.
- Breaking on a client before the host says so (predicted breaks): today a client's pane cracks half a round trip after the hit.
- FEMFX objects that aren't breakables (a thrown ball, soft bodies) aren't in snapshots: each machine simulates its own copy.
Voice¶
Voice chat is built in (kke::VoiceModule, KKE_ENABLE_VOICE, on by
default), with nothing to sign up for:
- Codec: Opus (BSD), 48 kHz mono, 20 ms frames at 24 kbit/s by default,
with in-band forward error correction: a single lost packet is rebuilt
from the next one.
Voicemessages go on the unreliable channel. - Talking: push-to-talk (the
voice.talkaction, B by default, rebindable like any action), voice activated (a gate that follows the room's noise floor, with a short hangover so word ends aren't cut), or open mic. "Hear myself" in the Voice panel tests the microphone. - Who hears it is decided by the server (
net::VoiceRules, set onNetModule::voiceRulesby a host): Nearby (proximity: withinproximityRange, 40 m by default, and heard from where the speaker stands), Team (ateam(playerId)function; unset = everyone), or Everyone (heard like a radio, not from a place). The server stamps who spoke (a client can't pretend to be someone else), caps each speaker at 60 packets a second, and never gives out anyone's address: all voice goes through it.kke_server's console hasmute/unmute. - Hearing: each speaker has a jitter buffer (60 ms cushion, reorders,
drops what arrives after its turn, conceals up to 100 ms of loss) and a
live
AudioStreamvoice in the mixer (category Voice), so voices are placed, occluded and reverberated like any other sound. Mute or set the volume of anyone locally;speaking(id)lets a game draw a speaker icon. - Cleaning the microphone (
kke::voice::VoiceCleaner), before coding, on by default, switches in the Voice panel: - Echo cancellation (SpeexDSP's MDF filter, BSD): without headphones
the microphone hears the speakers, and the others would hear
themselves back. The mixer hands voice chat everything it plays
(
AudioMixer::setOutputTap); an adaptive filter learns the path from speaker to microphone (up to 200 ms of delay and room echo) and subtracts it, then a residual echo suppressor takes out what's left. The panel shows how many dB it took out. Needs the audio output at 48 kHz (the default). - Noise suppression (RNNoise 0.1.1, BSD; its model is in the source, 416 KB): a small neural network trained on speech takes out fans, keyboards, traffic and hiss, which also keeps the voice gate from opening on noise.
- In the tests: steady noise 29 dB quieter; a speaker echo 25 ms late with a reflection 21 dB quieter after 4 s of learning.
KKE_VOICE=offkeeps the microphone closed;KKE_VOICE_NOISE=off/KKE_VOICE_ECHO=offswitch the cleaning off;KKE_VOICE_TONE=440sends a test tone instead of the microphone (not cleaned), to check a connection with nobody talking.
Checked end to end: two kke_demo instances, the host sending a tone, the client (at 7 fps under a software GPU) played 1188 frames with nothing skipped. Tests: test_voice.cpp (jitter buffer, voice gate, streamed mixer voices, routing, flood cap, Opus round trip with a lost frame, noise suppression, echo cancellation, the mixer's output tap). With a real microphone and speakers it waits for a person: HW-018 in docs/HARDWARE_TESTS.md.