Skip to content
Protocol

Protocol

This game does not mind being played by a program. The wire is documented in full — write your own client.

Transport

TCP. No TLS, and there won't be: instead there is an X25519 handshake against a pinned server key, and ChaCha20-Poly1305 per direction. No certificates, no authorities.

client → server   32 bytes   ephemeral public key
server → client   48 bytes   ephemeral public key + confirm tag

Keys are derived like this:

salt = Ec_pub || Es_pub
ikm  = DH(Ec, Es) || DH(Ec, S_static)
okm  = HKDF-SHA256(salt, ikm, "wif-transport-v1", 64)
k_c2s = okm[0..32]      k_s2c = okm[32..64]

The confirm tag is AEAD(k_s2c, nonce 0, aad = salt, empty body). Once it verifies, the client knows it is talking to the server that holds the pinned private key. A plain MITM cannot forge it.

Every frame after that:

[u32 ciphertext length][ChaCha20-Poly1305(u16 msg_id || payload)]

The nonce counter is 64-bit, one per direction. Server replies start at one: zero is spent on the confirm tag.

Ports

| port | purpose | | --- | --- | | 7101 | login server: login.wif.kz | | 7201 | world: game.wif.kz |

Entering

| id | dir | message | | --- | --- | --- | | 0x0001 | C→S | LoginRequest: str account, str password | | 0x0002 | S→C | LoginResponse: u8 status, on ok — u64 account_id, ticket, str world_host, u16 world_port | | 0x0010 | C→S | EnterWorld: ticket, str name (ignored), u16 proto_version | | 0x0011 | S→C | EnterWorldAck: u8 status, u32 eid, position, u8 tick_hz |

The name comes from the account behind the ticket, not from the message — otherwise it would be impersonation. The field only survives for framing.

Movement

Client positions are not accepted at all. The server computes them.

| id | dir | message | | --- | --- | --- | | 0x0017 | C→S | MoveInput: a batch of input commands, about 20 per second | | 0x0019 | S→C | Snapshot3: tick, world time, last processed command, own state and neighbours in your area of interest |

The client sends intent; the server answers with state and the number of the last command it processed, which is what the client reconciles against.

World and wish

| id | dir | message | | --- | --- | --- | | 0x0020 | S→C | WorldSpec: seed, generator version, ruleset version, parameters | | 0x0021 | S→C | RuleSet: the rules changed, rebuild the world | | 0x0022 | S→C | WorldPresentation: u64 world_id, u64 revision, 42 WINT bytes | | 0x0023 | S→C | WorldTransitionFrame: the current A→B transition, its progress and fencing token |

WINT v1 — exactly 42 bytes

bytes[4] magic = "WINT"
u16      version = 1
i32      mountain_amplitude_mm
i32      ocean_surface_level_mm
i32      ocean_depth_mm
u32      ocean_color_red_ppm
u32      ocean_color_green_ppm
u32      ocean_color_blue_ppm
u32      surface_gravity_um_per_s2
u32      day_length_ms
u8       star_count
u8       moon_count
u8       viewpoint     // 1 = planet, 2 = gas giant moon
u8       flags         // bit0 = rings; every other bit must be zero

The decoder accepts version 1 and exactly 42 bytes, nothing else. Ranges: mountains 0..400000 mm, ocean level −100000..100000 mm, depth 0..300000 mm, each colour channel 0..1000000 ppm, gravity 500000..30000000 µm/s², day 10000..86400000 ms, stars 1..3, moons 0..12.

Rings only exist from the gas giant moon viewpoint, and that viewpoint needs at least one moon. The compiler on the front page shows this live.

Numbers

Everything is little-endian, and everything that touches the world is an integer. Determinism beats convenience: the same inputs must produce the same world in C++, Swift and Python.