PaxNet
Сетевая игра для вашего мода: комната по коду, лобби, сообщения, которые доходят ровно один раз, и возврат после обрыва связи.
PaxNet
Online play for your mod: rooms with a join code, a lobby, messages delivered exactly once, and automatic return after a dropped connection.
Что это вообще такое
Представьте почту. Один игрок (хост) открывает «почтовое отделение» (комнату) и получает его адрес: короткий код вроде D3EF5YXQ. Друзья (гости) вводят этот код и попадают в то же отделение. Дальше любой может отправить «письмо» (сообщение) хосту, конкретному другу или всем сразу.
PaxNet следит, чтобы каждое письмо дошло ровно один раз и в том порядке, в каком его отправили. Даже если у друга на минуту пропал интернет, письма, отправленные за это время, дойдут после возвращения. Ни одно не потеряется и не придёт дважды.
Письма идут не напрямую между компьютерами, а через ретранслятор игры, сервер mp.paxuniverse.ru, которым уже пользуется сама Pax Universe. Поэтому не нужно открывать порты на роутере, знать IP-адрес друга или ставить VPN.
Для игрока
Включить мод в списке модов. Больше ничего делать не надо: им пользуются другие моды.
Для автора мода
Вызвать host() или join(), слать сообщения через send() и принимать их сигналом message.
Для продвинутых
Синхронизация целого мира: суммы разделов, лечение расхождений, передача мира без остановки игры.
Слова, которые встретятся дальше
Прочитайте один раз, и дальше всё будет понятно. Каждое слово объяснено обычным языком и на примере.
Хост
Игрок, который открыл комнату. В сети он всегда под номером 1. Он главный: решает спорные вопросы и рассылает «правду».
Гость
Игрок, который вошёл в чужую комнату по коду. Номера гостей: 2, 3, 4… в порядке входа.
Комната
Место на ретрансляторе, где встречаются хост и гости. Существует, пока хост в игре.
Код комнаты
От 4 до 12 знаков без похожих букв (нет 0, O, 1, I). Например D3EF5YXQ. Регистр не важен.
Приглашение
Ссылка с кодом внутри: https://mp.paxuniverse.ru/j/КОД. Её можно отправить другу в мессенджер.
Ретранслятор
Сервер игры, через который идут все сообщения. Его даёт сама Pax Universe через Network.relay_pipe().
Партия / сессия
Ваша сетевая игра целиком: комната, игроки, сообщения. В коде это узел Session.
Сообщение
Любые простые данные с «типом», то есть строкой, по которой вы поймёте, что пришло. Например тип "my_mod.chat", данные "Привет!".
Ровно один раз
Сообщение не потеряется и не придёт дважды, даже при обрыве связи. Порядок сохраняется.
Переподключение
Связь пропала и вернулась сама. Игрок возвращается под тем же номером, на то же место.
Ключ игрока
Секретная строка, которая хранится на компьютере игрока. По ней хост узнаёт вернувшегося игрока.
Как пропуск в офис.Лобби
Время до начала партии: игроки собираются, выбирают места, нажимают «готов».
Место (seat)
Номер страны или стороны, за которую играет игрок. У одного места один игрок, но могут быть соправители.
Адаптер
Ваш объект, через который PaxNet узнаёт о «мире» вашего мода. Нужен, только если вы синхронизируете целый мир.
Раздел мира
Кусок мира с именем: "score", "map". Сверяется и лечится отдельно от остальных.
Сумма (хеш)
Короткое число, которое получается из данных. Если данные у двух игроков разные, их суммы почти наверняка тоже разные.
Отпечаток пальца данных.Лечение
У гостя раздел разошёлся с хостом. Гость тихо просит только этот раздел, и хост присылает правильный.
Шаг мира
Один «тик» партии: номер, приказы, сколько дней прошло. Идут строго по порядку: 1, 2, 3…
Как поставить, если мод попросил PaxNet
Скачайте PaxNet Core из мастерской
Откройте лаунчер Pax Universe → Мастерская, найдите «PaxNet Core» и нажмите «Скачать».
Включите его в разделе «Моды»
Лаунчер → Моды → поставьте галочку у «PaxNet Core» и у мода, который его попросил. Игра применит моды при следующем запуске.
Первый сетевой мод: пошагово
hello host и получает код, второй вводит hello join КОД. После этого hello say Привет! показывает сообщение у всех. Это меньше 90 строк кода, и у каждого куска есть комментарий.Весь код на этой странице проверен: он компилируется в Godot 4.7 со строгой типизацией, как в игре, и проходит проверку песочницы.
Создайте папку мода
В папке
modsигры создайте папкуhello_net. Имя папки = id мода: латиница, цифры и_. Вот что будет внутри в конце:mods/ └── hello_net/ ├── mod.json ← паспорт мода: имя, версия, от чего зависит ├── main.gd ← код мода, первая строка: extends PaxMod └── data/ └── lang/ ├── ru.json ← тексты на русском └── en.json ← тексты на английскомНапишите mod.json: паспорт мода
Главная строка:
"depends": ["paxnet_core"]. Она говорит игре: «без PaxNet этот мод не работает, загрузи библиотеку раньше меня».{ "api": 1.0, "id": "hello_net", "name": {"en": "Hello Net", "ru": "Привет, сеть"}, "description": {"en": "Smallest PaxNet example: chat between players.", "ru": "Самый маленький пример PaxNet: чат между игроками."}, "version": "1.0.0", "entry": "main.gd", "game_version": ">=0.9.0", "authors": ["Вы"], "depends": ["paxnet_core"], "load_after": ["paxnet_core"], "tags": [] }Частая ошибкаЗабытьdepends. Тогда ваш мод может загрузиться раньше библиотеки и не найти её.Добавьте тексты в data/lang
В Pax Universe нельзя писать текст для игрока прямо в коде: всё, что видит игрок, лежит в файлах перевода (правило игры). В коде вы пишете ключ, например
tr_key("hello_net_me"), а игра подставляет текст на языке игрока. Ключи начинайте с id мода, чтобы не совпасть с чужими.{ "hello_net_no_lib": "Включите мод PaxNet Core и перезапустите игру.", "hello_net_me": "Игрок", "hello_net_wait": "Комната открывается… Через пару секунд наберите: hello code", "hello_net_line": "%s: %s", "hello_net_joined": "%s вошёл в партию", "hello_net_help": "hello host | hello code | hello join КОД | hello say ТЕКСТ | hello leave" }Файл
en.jsonустроен так же, но по-английски. Нужны как минимум эти два языка.Напишите main.gd: сам мод
Первая строка всегда
extends PaxMod. Прочитайте комментарии: в них сказано, что делает каждый кусок.extends PaxMod ## Самый маленький сетевой мод на PaxNet: чат между игроками через консоль (F8). ## Тип нашего сообщения. Начинайте с id своего мода: так типы разных модов не путаются. const MSG_SAY: String = "hello_net.say" ## Сетевая партия (узел net/Session.gd из библиотеки). null, если ещё не создана. var net: Node = null func _mod_loaded() -> void: Pax.register_command("hello", Callable(self, "_cmd"), tr_key("hello_net_help")) ## Библиотека PaxNet: узел, который она кладёт в метаданные корня. null, если библиотека не включена. func _lib() -> Node: var v: Variant = Pax.get_tree().root.get_meta("paxnet_lib", null) return v as Node if v is Node else null ## Партия создаётся один раз и живёт, пока идёт игра. func _net() -> Node: if net != null and is_instance_valid(net): return net var lib: Node = _lib() if lib == null: return null net = lib.call("new_session") net.connect("message", _on_message) net.connect("event", _on_event) net.connect("failed", _on_failed) return net func _cmd(args0: Variant = []) -> String: var args: Array = [] if args0 is String: args = Array((args0 as String).split(" ", false)) elif args0 is Array: args = args0 var n: Node = _net() if n == null: return tr_key("hello_net_no_lib") var what: String = str(args[0]) if args.size() > 0 else "" match what: "host": var err: String = str(n.call("host", tr_key("hello_net_me"))) return err if err != "" else tr_key("hello_net_wait") "code": return str(n.call("invite_link")) "join": if args.size() < 2: return tr_key("hello_net_help") var err2: String = str(n.call("join", str(args[1]), tr_key("hello_net_me"))) return err2 if err2 != "" else "ok" "say": var text: String = " ".join(PackedStringArray(args.slice(1))) n.call("broadcast", MSG_SAY, text) return tr_key("hello_net_line") % [tr_key("hello_net_me"), text] "leave": n.call("leave") return "ok" return tr_key("hello_net_help") ## Пришло сообщение от другого игрока. func _on_message(from_id: int, type: String, data: Variant) -> void: if type == MSG_SAY: var who: String = str(net.call("player_name", from_id)) _toast(tr_key("hello_net_line") % [who, str(data)]) ## События партии: здесь только «кто-то вошёл» (это видит хост). func _on_event(kind: String, data: Dictionary) -> void: if kind == "peer_joined": _toast(tr_key("hello_net_joined") % str(data.get("name", "?"))) ## Ошибка: показываем понятную причину из переводов библиотеки. func _on_failed(code: String) -> void: _toast(tr_key("paxnet_err_" + code) + " " + tr_key("paxnet_err_" + code + "_do")) func _toast(text: String) -> void: var g: Variant = Pax.get("game") if g is Object and (g as Object).has_method("toast"): (g as Object).call("toast", text) else: print(text)Что здесь главное_lib()достаёт библиотеку.new_session()создаёт партию.host()открывает комнату,join()входит в неё.broadcast()рассылает всем. Сигналmessageсрабатывает, когда сообщение пришло к вам.Проверьте мод
В папке игры выполните команду. Код выхода
0означает, что ошибок нет:godot --headless --path <папка игры> -- --check-mods hello_netВключите оба мода и запустите игру на двух компьютерах
Лаунчер → Моды → галочки у «PaxNet Core» и «Привет, сеть». Перезапуск.
Сыграйте
Нажмите F8, чтобы открыть консоль.
Кто Что набрать Что произойдёт Хост hello host«Комната открывается…». Через 1–2 секунды комната готова. Хост hello codeСсылка-приглашение, например https://mp.paxuniverse.ru/j/D3EF5YXQ. Отправьте её другу.Друг hello join D3EF5YXQДруг входит в комнату, у хоста всплывает «Игрок вошёл в партию». Можно вставить и всю ссылку целиком. Любой hello say Привет!У всех остальных всплывает «Игрок: Привет!». Любой hello leaveВыход. Если вышел хост, гости увидят «Хост закрыл партию». ГотовоЭто полноценная сетевая игра: обрывы связи, порядок и повторы за вас уже обработаны. Дальше идут справочник и рецепты: движение в реальном времени, ходы по очереди, общий кубик, комната дня.
Как летит одно сообщение
Гость вызвал send(1, "my_mod.chat", "Привет"). Вот что происходит дальше, сверху вниз. Знать это не обязательно, но так проще понять ошибки.
Сообщение получает номер в очереди: 1, 2, 3… По этим номерам получатель потом отсеет повторы и восстановит порядок.
Данные переводятся в байты своим кодеком. Если вышло больше 4 КБ, байты сжимаются. Большое сообщение режется на куски.
Куски складываются в пакеты до 1200 байт. Каждый пакет подписан ключом, который знают только этот гость и хост. Подделать пакет нельзя.
Пакет уходит в канал ретранслятора, адресат: хост, номер 1.
Сервер Pax Universe пересылает пакет хосту по защищённому соединению (wss, порт 443).
Хост проверяет подпись, собирает куски, распаковывает. Подтверждает: «получил всё до номера N».
Если сообщение было для хоста, у хоста срабатывает сигнал message. Если для другого гостя или для всех, хост пересылает его дальше тем же путём и сам указывает настоящего автора.
Кто кому может написать
| Вызов | Кто получит | Поле from_id у получателя |
|---|---|---|
send(1, …) у гостя | Только хост | номер гостя |
send(3, …) у гостя №2 | Только гость №3 (через хоста) | 2 |
send(0, …) или broadcast(…) у гостя | Хост и все гости, кроме автора | номер автора |
send(2, …) у хоста | Только гость №2 | 1 |
broadcast(…) у хоста | Все гости | 1 |
send(свой_номер, …) | Никто: себе сообщения не приходят | нет |
Статусы: где сейчас ваша партия
У сессии есть поле status. Оно меняется само, а при каждом изменении срабатывает сигнал changed. Смотрите на статус, чтобы решить, что показать игроку.
connectingОткрываем комнату или входим в неё. Обычно 1–2 секунды.
lobbyВсе в комнате, партия ещё не началась. Сообщения уже ходят.
syncingГость получает мир от хоста. Игра у хоста при этом не стоит.
playingПартия идёт.
reconnectingСвязь пропала, PaxNet сам возвращает игрока. Ничего делать не нужно.
failedСвязь не вернулась. Покажите игроку кнопку «Войти снова» (rejoin()).
closedХост закрыл партию или выгнал игрока. Возвращаться некуда.
Ещё "" (пустая строка): партии нет, сессия просто создана или из неё вышли через leave().
syncing, а playing наступает после start(). Сообщения работают уже в lobby: если в вашем моде нет «начала партии», start() можно вовсе не вызывать.PaxNetLib: главный вход в библиотеку
Библиотека создаёт узел /root/PaxNetLib и сразу, ещё до того как он появится в дереве, кладёт его в метаданные корня. Поэтому доставайте его так, это работает даже в _mod_loaded:
## Достать библиотеку. Работает уже в _mod_loaded: узел лежит в метаданных корня.
func get_paxnet() -> Node:
var v: Variant = Pax.get_tree().root.get_meta("paxnet_lib", null)
if v is Node:
return v as Node
return null # библиотека не включена: покажите игроку подсказкуload() и preload() только по путям своего мода. Строка preload("res://mods/paxnet_core/…") в вашем моде будет отклонена. Всё нужное отдаёт сам узел библиотеки.| Метод | Что делает | Пример |
|---|---|---|
| new_session(parent = null) → Node | Создаёт новую сетевую партию (Session) и вешает её в дерево. Ключ игрока подставляется сам. | var net: Node = lib.new_session() |
| open_pipe(first) → Object | Голый канал ретранслятора для своего протокола (как в doom_2). Смотрите рецепт 6. | lib.open_pipe({"op": "join", "code": c}) |
| overlay(session, parent) → CanvasLayer | Строка состояния вверху экрана для партии: код, игроки, пинг, понятная ошибка с кнопкой. | lib.overlay(net, game.hud_layer()) |
| encode(value) → PackedByteArray | Значение в байты. Свой кодек: объекты не кодируются, глубина и длины ограничены. | var b: PackedByteArray = lib.encode({"hp": 5}) |
| decode(bytes) → Dictionary | Байты в значение: {ok, v}. Мусор не роняет игру, вернётся ok = false. | var d: Dictionary = lib.decode(b) |
| rng(seed, tick = 0, stream = "") → RefCounted | Генератор случайностей, одинаковый у всех игроков. Методы: frand(), irange(a, b), chance(p), pick(arr), next_u32(). | lib.rng(seed, turn, "my_mod/dice") |
| state_hash(value) → int | Отпечаток значения (32 бита), не зависит от порядка ключей словаря. Для сверки. | lib.state_hash(world) |
| parse_code(text) → String | Код комнаты из того, что ввёл игрок: код, PAX-…, ссылка. "" значит, что это не код. | lib.parse_code("pax-d3ef5yxq") |
| invite_link(code) → String | Ссылка-приглашение для кода. | lib.invite_link("D3EF5YXQ") |
| shared_code(seed, stream, prefix = "") → String | Код комнаты, одинаковый у всех при одинаковых seed и stream. Всего 8 знаков, prefix: до 4 знаков в начале. | lib.shared_code(lib.utc_day(), "my_mod/daily", "MM") |
| utc_day() → int | Номер текущего дня по UTC: одинаков у всех игроков мира. | lib.utc_day() |
| player_key() → String | Постоянный ключ игрока на этом компьютере (общий для всех модов). | lib.player_key() |
| script_of(name) → Script | Скрипт библиотеки по имени: Proto, Bytes, SharedRng, StateHash, Delta, Crc32, Hmac, Adapter, BasicAdapter, HostSession, GuestSession, Mailbox, TimeAuthority, Relay, RelayPipe, PipeHost, PipeGuest, Session, NetOverlay. | var RP: Script = lib.script_of("RelayPipe") |
| world_service() → Node | Партия в мире самой Pax Universe (стратегия). null, если в этой версии игры она недоступна. | lib.world_service().host_open("Аня") |
| world_service_if_any() → Node | То же, но не создаёт: null, если её ещё нет. | |
| pipe_detail() → String | Что нашлось вместо канала при последней неудаче. Для журнала и поддержки. | print(lib.pipe_detail()) |
Методы вызывайте через lib.call("имя", …) или просто lib.имя(…): GDScript разрешает вызов по имени у объекта типа Node. Результат такого вызова не имеет типа, поэтому сразу укажите тип переменной: var net: Node = lib.new_session(). Запись var net := lib.new_session() игра не скомпилирует.
Session: ваша сетевая партия
Узел, который возвращает lib.new_session(). Он уже стоит в дереве и обрабатывается даже на паузе игры. Одна сессия соответствует одной партии; после leave() ту же сессию можно использовать снова.
Пометки: хост работает только у хоста, гость только у гостя, оба у всех.
Открыть, войти, уйти
| Метод | Кто | Что делает | Пример |
|---|---|---|---|
| host(name, adapter = null, cfg = {}) → String | хост | Открыть комнату. name: имя игрока, которое увидят все. adapter: нужен только для синхронизации мира, иначе null. cfg: настройки комнаты. Вернёт "", если открытие началось, или код ошибки. Код комнаты появится через 1–2 секунды (событие room, статус lobby). | net.host("Аня") |
| join(target, name, adapter = null, extra = {}) → String | гость | Войти по коду, «PAX-…» или ссылке. Вернёт "" (входим, ждите статус lobby) или код ошибки: "code", если введён явно не код, "relay", если канал ретранслятора не открылся. Если комнаты с таким кодом нет, это выяснится позже: придёт сигнал failed("code"). extra.mods сверяется с cfg.mods хоста. | net.join("D3EF5YXQ", "Борис") |
| start() → Dictionary | хост | Начать партию: статус playing, гости получают мир (или пустой мир, если адаптера нет). Вернёт {ok, code}. Не обязателен, если в вашем моде нет «начала». | net.start() |
| leave() | оба | Уйти. Гость прощается с хостом. Хост прощается со всеми и закрывает комнату. Вызывается сам, когда узел удаляется. | net.leave() |
| rejoin() → String | гость | Войти снова после ошибки связи (статус failed): тот же ключ, то же место. | net.rejoin() |
| request_full_world() | гость | Попросить мир целиком, если он разошёлся и не вылечился (ошибка desync). |
Сообщения
| Метод | Кто | Что делает | Пример |
|---|---|---|---|
| send(to, type, data = null) → bool | оба | Отправить сообщение. to: 1 (хост), 2… (гость), 0 (все, кроме себя). Доходит ровно один раз и по порядку. Вернёт false, если партии нет. | net.send(1, "my_mod.hi", "привет") |
| broadcast(type, data = null) → bool | оба | То же, что send(0, …): всем, кроме себя. | net.broadcast("my_mod.pos", Vector2(3, 4)) |
| action(kind, args = null) → Variant | оба | Приказ через шаг мира: у всех в одном порядке. Нужен только с адаптером. Работает только после start(): в лобби приказ отклоняется кодом illegal. Если action_policy адаптера вернула "own" (так по умолчанию), у автора должно быть место. Гостю вернёт номер приказа, хосту вернёт {ok, code}. | net.action("my_mod.build", {"x": 2}) |
Лобби
| Метод | Кто | Что делает | Пример |
|---|---|---|---|
| set_ready(on) | оба | Отметка «готов». | net.set_ready(true) |
| choose_seat(seat, as_coruler = false) | оба | Занять место (страну, сторону) по номеру из countries() адаптера. Если место занято, ничего не изменится: проверяйте players() после сигнала changed. Хосту lobby_cmd вернёт {ok: false, code: "seat_taken"}. | net.choose_seat(0) |
| chat(text) | оба | Строка чата всем (до 300 знаков). Приходит в адаптер on_chat. Для своего чата проще send/broadcast. | net.chat("gg") |
| kick(id) | хост | Выгнать игрока: он увидит «Хост убрал вас из партии». | net.kick(3) |
| lobby_cmd(cmd) → Variant | оба | Любая команда лобби: {op: "seat"|"ready"|"name"|"rules"|"kick"|"start", …}. rules доступна только хосту: time_mode, join_in_progress, ready_required, allow_coruler, max_players. | net.lobby_cmd({"op": "name", "name": "Аня"}) |
Время партии
| Метод | Кто | Что делает | Пример |
|---|---|---|---|
| time_cmd(kind, value = null) | оба | kind: "pause", "resume", "speed" (value от 1 до 5). При time_mode "host" хост откажет гостю: код host_only придёт в адаптер (on_notice). При "vote" начнётся голосование. | net.time_cmd("pause") |
| time_state() → Dictionary | оба | {paused, speed, by, by_name, mode, votes, v}: стоит ли пауза, кто её поставил, идёт ли голосование. |
Узнать состояние
| Метод | Кто | Что делает | Пример |
|---|---|---|---|
| is_host() / is_guest() → bool | оба | Кто я в этой партии. | |
| active() → bool | оба | В партии и связь есть: статус lobby, syncing или playing. | |
| my_id() → int | оба | Мой номер: 1 у хоста, 2… у гостя. 0, если гость ещё не вошёл. | |
| code() → String / invite_link() → String | оба | Код комнаты и ссылка-приглашение. | |
| players() → Array | оба | Все игроки: [{id, name, seat, role, ready, ai, ping, host, away}]. away: связь пропала, место держится. | |
| player_name(id) → String | оба | Имя игрока по номеру ("?", если не найден). | |
| rtt_ms() → float | гость | Пинг до хоста в миллисекундах. | |
| state() → Dictionary | оба | Всё сразу: role, status, code, players, time, notice, rtt, id. Удобно для своего окна лобби. |
Сигналы
| Сигнал | Когда срабатывает |
|---|---|
| message(from_id: int, type: String, data: Variant) | Пришло сообщение. from_id: номер автора (1 означает хоста). Главный сигнал. |
| changed() | Что-то изменилось: статус, состав, готовность, время, ошибка. Удобно перерисовывать окно лобби. |
| failed(code: String) | Партия не удалась или закрыта. code берётся из таблицы ошибок. |
| event(kind: String, data: Dictionary) | Подробные события, список ниже. |
События event
| kind | У кого | Что значит | data |
|---|---|---|---|
room | хост | Комната открыта, код готов | {code} |
reopening | хост | Ретранслятор потерял комнату, поднимаем её снова с тем же кодом | {try} |
relay_error | хост | Ретранслятор не дал комнату | {code, why} |
peer_joined | хост | Игрок вошёл или вернулся | {id, name, reconnect, mode} |
peer_left | хост | Игрок ушёл или пропал (место за ним держится) | {id, why}; why: bye, timeout, kicked |
lobby | оба | Обновилось лобби | состояние лобби целиком |
started | хост | Партия началась | {} |
world_sent / world_delivered | хост | Мир отправлен гостю / гость его собрал | {id, …} |
step | хост | Сделан шаг мира | {t, n} |
connected | гость | Соединились с хостом (и после возврата) | {id, mode, reconnect} |
lost | гость | Связь пропала, начинаем возвращаться | {} |
world_installed | гость | Мир хоста получен и установлен | {tick} |
patched / mismatch | гость | Пришли изменения мира / найдено расхождение (будет вылечено) | {names, tick} |
action_rejected | гость | Хост не принял приказ | {c, ok, code} |
closed / failed | гость | Хост закрыл партию / связь не вернулась | {why} / {code} |
Поля, которые можно читать
| Поле | Что в нём |
|---|---|
role | "host", "guest" или "" |
status | Смотрите статусы |
last_notice | Последняя ошибка: {what, do, code, args}. what и do являются ключами перевода: tr_key(n["what"]) |
player_key | Ключ игрока. Его выдаёт библиотека, общий для всех модов |
defaults | Настройки комнаты по умолчанию (смотрите ниже) |
session, transport, adapter | Внутренности (HostSession/GuestSession, PipeHost/PipeGuest, ваш адаптер), для продвинутых |
Настройки комнаты
Третий аргумент host(name, adapter, cfg) является словарём. Всё необязательно: если ключ не указан, берётся значение по умолчанию.
net.host("Аня", null, {"max_players": 4, "time_mode": "vote", "join_in_progress": false})| Ключ | По умолчанию | Что значит |
|---|---|---|
max_players | 5 | Сколько людей в партии вместе с хостом. Шестой получит ошибку full. |
join_in_progress | true | Можно ли входить, когда партия уже идёт. При false новенькие получают started (вернувшиеся по ключу проходят всегда). |
time_mode | "host" | Кто двигает время. "host": только хост. "vote": голосование (паузу может сразу поставить любой, а снять паузу или сменить скорость можно большинством голосов за 15 секунд). |
allow_coruler | true | Можно ли играть вдвоём за одно место (соправитель). |
ready_required | false | start() не сработает, пока все не нажали «готов» (вернёт {ok: false, code: "not_ready"}). Для not_ready в библиотеке нет готового текста paxnet_err_…: сообщение игроку напишите сами. |
room_name | имя хоста | Название комнаты на ретрансляторе. |
public | false | Просьба ретранслятору показать комнату в общем списке. Вход по коду работает всегда. |
preset | "paxnet" | Метка комнаты для ретранслятора. Поставьте id своего мода. |
want_code | "" | Попросить конкретный код (продолжение сохранённой партии, комната дня). Если код занят, ретранслятор может дать другой. Смотрите code() после события room. |
mods | "" | Строка-«отпечаток» модов. Если не пусто, гость с другой строкой в join(…, {"mods": …}) получит ошибку mods. |
authority | false | Для синхронизации мира: При true мир считает только хост и рассылает изменения. Смотрите свой мир. |
external_time | false | При true шаги мира делаете вы сами вызовом session.record_step(дни), а не таймер скорости. До start() вызов ничего не делает. |
push_min_ms | 0 | В режиме authority: не чаще скольки миллисекунд рассылать изменения мира. |
Что можно отправлять
В data кладите только простые значения. Объекты (узлы, ресурсы, скрипты) по сети не ходят никогда: так чужой игрок не сможет прислать вам вредоносный объект.
| Тип | Можно? | Заметка |
|---|---|---|
null, bool, int, float, String | ✅ | StringName придёт как String |
Array, Dictionary | ✅ | Вложенность до 48 уровней. Ключи словаря тоже должны быть простыми значениями |
PackedByteArray | ✅ | Передаётся как есть: удобно для своих двоичных форматов |
PackedInt32/64Array, PackedFloat32/64Array, PackedStringArray | ✅ | Придут обычным Array |
Vector2, Vector3, Color, Vector2i | ✅ | Vector2/3 и Color передаются с точностью float32 |
Rect2, Transform, Quaternion и прочее | ❌ придёт null | Разложите на числа или массивы |
Object, Node, Resource, Callable | ❌ придёт null | Отправляйте id или имя, а не сам объект |
Ограничения
| Что | Предел | Что будет, если больше |
|---|---|---|
| Одно сообщение после сжатия | ≈ 4,5 МБ (4096 кусков по ~1,1 КБ) | Не отправится. Делите сами |
| Сжатие | Включается само для сообщений от 4 КБ | ничего |
| Тип сообщения | Любая строка; принято "id_мода.что" | ничего |
Приказ action() | до 700 байт, вид до 40 знаков, ~20 в секунду (запас 40) | Отклонён: illegal или rate |
Чат chat() | 300 знаков, примерно 6 строк подряд | Строка отброшена, автор получит rate |
Частота send() | Жёсткого предела нет | 10–20 раз в секунду на игрока нормально, а 60 раз в секунду крупными сообщениями уже много |
if data is Dictionary:, if data is Vector2:. Не верьте числам вслепую: проверяйте, что координата не за картой, а количество не отрицательное.Ошибки и что с ними делать
Ошибка приходит строкой-кодом: из host() или join() (если не удалось сразу), в сигнале failed(code) и в last_notice.code. Для каждого кода в библиотеке есть два готовых текста на русском и английском: paxnet_err_КОД (что случилось) и paxnet_err_КОД_do (что нажать).
func _on_failed(code: String) -> void:
var what: String = tr_key("paxnet_err_" + code) # «Такого кода нет или приглашение устарело.»
var todo: String = tr_key("paxnet_err_" + code + "_do") # «Проверьте код у друга…»
game.toast(what + " " + todo)code, full, relay, timeout, host_left, kicked, version, mods, started, no_route, desync) приходят в сигнал failed. Отказы на отдельные действия (illegal, host_only, rate) партию не ломают: они приходят в метод on_notice вашего адаптера и в событие action_rejected.| Код | Когда бывает | Что увидит игрок | Что ему делать |
|---|---|---|---|
version | У гостя другая версия протокола PaxNet, чем у хоста. | Версия сетевой игры у вас и у хоста разная. | Обновите игру до версии хоста и войдите снова. |
mods | У хоста задан cfg.mods, а строка mods гостя другая. | Набор модов не совпал с хостом. | Включите те же моды, что у хоста, и войдите снова. |
full | Людей в партии (вместе с хостом) уже max_players, или ретранслятор сказал, что комната полна. | В партии нет свободных мест. | Попросите хоста освободить место. |
code | Введён не код (неверные знаки, длина) или такой комнаты нет. | Такого кода нет или приглашение устарело. | Проверьте код у друга или попросите новое приглашение. |
banned | Зарезервировано: сейчас библиотека этот код не выдаёт. | Хост закрыл вам вход. | Напишите хосту. |
started | Партия идёт, а у хоста join_in_progress = false. Вернувшиеся по ключу проходят всегда. | Партия уже идёт, вход закрыт правилами хоста. | Попросите хоста разрешить вход в идущую партию. |
seat_taken | choose_seat на место, где уже есть игрок. | Эту страну уже ведёт другой игрок. | Выберите другую страну или станьте соправителем. |
timeout | От хоста нет пакетов дольше 15 секунд и вернуться не удалось. | От хоста давно нет вестей: связь пропала. | Игра сама пробует вернуться. Можно нажать «Войти снова»: вы вернётесь в свою страну. |
kicked | Хост вызвал kick(id). | Хост убрал вас из партии. | Напишите хосту. |
host_left | Хост вышел (leave) или его комнаты больше нет на ретрансляторе. | Хост закрыл партию. | Дождитесь, пока хост запустит её снова, и войдите по тому же коду. |
illegal | Приказ или команда не по правилам: не та фаза, нет места, слишком длинный вид приказа. | Приказ не принят: так делать нельзя. | Прочитайте причину в окне приказа и поправьте приказ. |
host_only | Гость пытается сделать то, что можно только хосту: двигать время при time_mode "host", менять правила. | Время в этой партии двигает хост. | Попросите хоста поставить паузу или сменить скорость. |
rate | Слишком часто: больше ~40 приказов подряд или ~6 строк чата за несколько секунд. | Слишком много команд подряд. | Подождите секунду. |
bad_packet | Зарезервировано: повреждённые пакеты отбрасываются молча. | Пришли повреждённые данные. | Если повторится, войдите снова. |
desync | Мир не удалось установить: install_world адаптера вернул false. | Ваш мир разошёлся с миром хоста и сам не вылечился. | Нажмите «Войти снова», и мир придёт заново за несколько секунд. |
no_route | Рукопожатие с хостом не прошло за 8 секунд. | Хост не отвечает. | Нажмите «Войти снова»: игра ещё раз войдёт в комнату через ретранслятор. |
relay | Нет Network.relay_pipe() в игре, ретранслятор отказал или не ответил (хост ждёт 20 с, гость 10 с). | Ретранслятор Pax Universe недоступен. | Проверьте интернет и версию игры и попробуйте через минуту. |
Готовые решения: копируйте и меняйте
В примерах lib обозначает библиотеку, net обозначает сессию из lib.new_session(). Типы сообщений начинаются с my_mod.: замените на id своего мода.
1. Позиции игроков в реальном времени
Каждый игрок 10 раз в секунду сообщает всем, где он. Этого достаточно для плавного движения: между сообщениями позицию сглаживают у себя, например через lerp.
const MSG_POS: String = "my_mod.pos"
var _pos_timer: float = 0.0
## Где сейчас другие игроки: id игрока → позиция.
var others: Dictionary = {}
## Зовите каждый кадр из своего _process: 10 раз в секунду рассылает свою позицию.
func send_my_position(delta: float, my_pos: Vector2) -> void:
_pos_timer += delta
if _pos_timer < 0.1 or not bool(net.call("active")):
return
_pos_timer = 0.0
net.call("broadcast", MSG_POS, my_pos) # всем, кроме себя
## Из обработчика сигнала message.
func on_position(from_id: int, data: Variant) -> void:
if data is Vector2:
others[from_id] = data2. Ходы по очереди: хост судит
Самая надёжная схема для пошаговых игр и всего, где можно сжульничать. Гость только просит сделать ход. Хост проверяет правила и рассылает всем новое состояние. Гости верят только хосту.
const MSG_MOVE: String = "my_mod.move" # гость → хост: «хочу сходить»
const MSG_STATE: String = "my_mod.state" # хост → все: «вот как теперь на доске»
var board: Dictionary = {"turn": 0, "cells": {}}
## Игрок нажал на клетку.
func try_move(cell: Vector2i) -> void:
if bool(net.call("is_host")):
_judge(1, cell) # хост судит и свои ходы
else:
net.call("send", 1, MSG_MOVE, cell) # гость только просит
func on_judge_message(from_id: int, type: String, data: Variant) -> void:
if type == MSG_MOVE and bool(net.call("is_host")) and data is Vector2i:
_judge(from_id, data)
elif type == MSG_STATE and from_id == 1 and data is Dictionary:
board = data # гость верит только хосту
func _judge(player_id: int, cell: Vector2i) -> void:
var cells: Dictionary = board["cells"]
var key: String = "%d,%d" % [cell.x, cell.y]
if cells.has(key):
return # клетка занята: ход не принят
cells[key] = player_id
board["turn"] = int(board["turn"]) + 1
net.call("broadcast", MSG_STATE, board) # все гости получают новую доску3. Общий кубик: случайность, одинаковая у всех
Обычный randi() у каждого игрока выдаёт своё. lib.rng(зерно, шаг, "поток") выдаёт у всех одно и то же, если совпадают три входа: на любом компьютере и любой версии движка.
## Кубик, который у всех игроков выпадает одинаково. seed хост раздаёт один раз в начале
## партии (например, в сообщении "my_mod.seed"), turn: номер хода.
func roll_dice(seed: int, turn: int) -> int:
var r: RefCounted = lib.call("rng", seed, turn, "my_mod/dice")
return int(r.call("irange", 1, 6))Методы генератора: next_u32(), frand() (от 0 до 1), irange(a, b) (целое от a до b включительно), chance(p), pick(массив).
4. Комната дня
Все, кто играет сегодня, попадают в одну комнату без обмена кодами: код вычисляется из номера дня.
## Комната дня: у всех игроков сегодня один и тот же код. Первый пришедший её открывает,
## остальные входят.
var _daily_code: String = ""
func enter_daily_room(my_name: String) -> void:
_daily_code = str(lib.call("shared_code", int(lib.call("utc_day")), "my_mod/daily", "MM"))
net.connect("failed", _on_daily_failed, CONNECT_ONE_SHOT)
net.call("join", _daily_code, my_name)
func _on_daily_failed(code: String) -> void:
if code == "code":
# такой комнаты ещё нет: открываем её сами с этим кодом
net.call("host", "Daily", null, {"want_code": _daily_code, "max_players": 16})5. Строка состояния на экране
Одна строка вверху экрана с кодом, игроками и пингом. При ошибке появляется окно с объяснением и кнопкой «Войти снова» или «Понятно». Тексты библиотека берёт из своих переводов.
## Строка состояния вверху экрана: код, игроки, пинг, понятная ошибка с кнопкой «Войти снова».
func show_status_line(game: PaxGame) -> void:
lib.call("overlay", net, game.hud_layer())6. Голый канал: свой протокол целиком
Если сессии PaxNet вам не подходят (так сделан doom_2), берите канал ретранслятора напрямую. Тогда порядок, повторы и переподключение становятся вашей заботой. Канал игры уже надёжный и упорядоченный, но при обрыве всё, что было в пути, теряется.
## Голый канал ретранслятора без сессий PaxNet: свой протокол целиком на вас
## (так сделан doom_2). У хоста в канале номер 1, у гостей 2, 3…; 0 означает «всем гостям».
var pipe: Object = null
var RP: Script = null
func open_raw_room() -> void:
RP = lib.call("script_of", "RelayPipe")
pipe = lib.call("open_pipe", {"op": "host", "name": "my room", "public": false, "max": 4,
"ai": "", "preset": "my_mod", "host_name": "Host", "code": ""})
if pipe == null:
push_warning("relay: " + str(lib.call("pipe_detail")))
return
RP.call("wire", pipe, self, "_raw_") # сигналы канала → _raw_lobby_ready и т.д.
func _raw_lobby_ready(code: Variant, _rules: Variant = null) -> void:
print("room code: ", code)
func poll_raw() -> void: # каждый кадр
RP.call("poll", pipe)
while true:
var pk: Array = RP.call("recv", pipe) # [от кого, байты] или []
if pk.is_empty():
break
var d: Dictionary = lib.call("decode", pk[1])
if bool(d["ok"]):
print("from ", pk[0], ": ", d["v"])
func raw_send_all(value: Variant) -> void:
RP.call("send", pipe, 0, lib.call("encode", value))Свой мир: сверка и лечение
Идея
Мир делится на разделы, то есть словари с именами ("score", "map", "units"). Для каждого раздела считается сумма. Хост рассылает свои суммы. Гость сравнивает их со своими. Если раздел "map" не совпал, гость тихо просит у хоста только его. Хост присылает, гость вставляет, и мир снова одинаковый, игра не останавливалась.
Два режима
Мир считает хост authority: true
Хост меняет мир и вызывает record_step(). Гостям уходят только изменения (разница) вместе с суммами. Гости ничего не пересчитывают сами. Подходит почти всем.
Все считают одинаково authority: false
Каждый шаг у всех одинаковые приказы, число дней и случайности. Каждый пересчитывает мир сам, суммы ловят расхождения (как в Factorio). Нужен строго детерминированный код.
Адаптер: ваш мир для PaxNet
Адаптер представляет собой объект с методами, которые PaxNet вызывает по имени. Наследовать что-то из библиотеки не нужно (это и невозможно: чужой extends песочница не пустит). Нужно, чтобы были все методы. Вот полный рабочий пример: мир с очками и флагом. Он проверен сетевым тестом: мир доставляется, изменения доходят.
extends RefCounted
## Мир для сверки: очки игроков и флаг на карте. Ядро PaxNet зовёт эти методы по имени,
## поэтому наследовать Adapter.gd библиотеки не нужно: достаточно, чтобы все методы были.
## Мир считает хост (cfg "authority": true): гости получают изменения разницей и сверяют суммы.
## Разделы мира: имя → простые данные (числа, строки, массивы, словари).
var sections: Dictionary = {"score": {}, "flag": {"owner": "", "x": 0, "y": 0}}
var day: int = 0
## Куда мод выводит события (необязательно).
var on_changed: Callable = Callable()
# ---------- мир как набор разделов ----------
func section_names() -> Array:
return ["flag", "score"]
func section_period(_name: String) -> int:
return 1
func hashes(_tick: int, _all: bool = false) -> Dictionary:
var h: Dictionary = {}
for n in sections:
h[n] = _hash(sections[n])
return h
func hashes_view(_view: Variant, tick: int, all: bool = false) -> Dictionary:
return hashes(tick, all)
func section_data(names: Array) -> Dictionary:
var out: Dictionary = {}
for n in names:
if sections.has(n):
out[n] = (sections[n] as Dictionary).duplicate(true)
return out
func section_data_view(_view: Variant, names: Array) -> Dictionary:
return section_data(names)
func apply_sections(data: Dictionary) -> void:
for n in data:
sections[n] = data[n]
if on_changed.is_valid():
on_changed.call()
func world_snapshot_view(_view: Variant) -> PackedByteArray:
return var_to_bytes({"s": sections, "d": day})
func install_world(bytes: PackedByteArray) -> bool:
var v: Variant = bytes_to_var(bytes)
if not (v is Dictionary) or not (v as Dictionary).has("s"):
return false
sections = (v as Dictionary)["s"]
day = int((v as Dictionary)["d"])
if on_changed.is_valid():
on_changed.call()
return true
func view_of(_seat: int) -> Variant:
return ""
func begin_views(_tick: int) -> void:
pass
# ---------- ход игры ----------
func current_day() -> int:
return day
func advance(days: int, _tick: int, _world_seed: int) -> void:
day += days
func action_policy(_kind: String) -> String:
return "free"
func execute_action(_actor: Dictionary, _action: Dictionary, _ctx: Dictionary) -> Dictionary:
return {"ok": false, "code": "illegal", "text": ""}
func apply_event(_ev: Dictionary) -> void:
pass
func apply_deal(_kind: String, _from_seat: int, _to_seat: int, _terms: Dictionary) -> Dictionary:
return {"ok": true}
func auto_reply(_kind: String, _from_seat: int, _to_seat: int, _terms: Dictionary) -> bool:
return false
func countries() -> Array:
return [{"id": 0, "name": "red"}, {"id": 1, "name": "blue"}]
# ---------- вывод игроку (на мир не влияет) ----------
func on_news(_e: Dictionary) -> void:
pass
func on_ai_result(_e: Dictionary) -> void:
pass
func on_action_result(_actor: Dictionary, _action: Dictionary, _result: Dictionary) -> void:
pass
func on_chat(_from_id: int, _name: String, _text: String) -> void:
pass
func on_notice(_code: String, _args: Dictionary) -> void:
pass
func on_roster_changed(_players: Array) -> void:
pass
func on_progress(_what: String, _ratio: float) -> void:
pass
func on_time_state(_state: Dictionary) -> void:
pass
# Хеш, не зависящий от порядка ключей словаря (свой, потому что hash() словаря зависит от порядка).
static func _hash(v: Variant) -> int:
if v is Dictionary:
var sum: int = 13
for k in v:
sum += hash(str(k)) * 31 + _hash((v as Dictionary)[k])
return sum & 0xFFFFFFFF
return hash(v) & 0xFFFFFFFFКак им пользоваться
var world: RefCounted = load("res://mods/my_mod/ScoreWorld.gd").new()
func host_world() -> void:
net.host("Аня", world, {"authority": true, "external_time": true})
func join_world(code: String) -> void:
var my_world: RefCounted = load("res://mods/my_mod/ScoreWorld.gd").new()
net.join(code, "Борис", my_world) # у гостя свой экземпляр, PaxNet его заполнит
# Хост что-то поменял в мире: фиксируем шаг, гости получат изменения.
# Работает только после net.start(): в лобби record_step ничего не делает.
func add_point(player: String) -> void:
var score: Dictionary = world.sections["score"]
score[player] = int(score.get(player, 0)) + 1
var core: Object = net.session
core.record_step(0) # 0 дней: шаг только с изменениямиМетоды адаптера: что каждый должен делать
| Метод | Что вернуть или сделать |
|---|---|
| section_names() → Array | Имена разделов |
| section_period(name) → int | Раз в сколько шагов сверять раздел (тяжёлые реже). Обычно 1 |
| hashes(tick, all) → Dictionary | Сумма каждого раздела: {имя: число}. Сумма не должна зависеть от порядка ключей |
| hashes_view(view, tick, all) | То же для «вида» игрока. Если вид один на всех, вызовите hashes |
| section_data(names) → Dictionary | Копии разделов целиком: {имя: данные} |
| section_data_view(view, names) | То же для вида |
| apply_sections(data) | Вставить присланные разделы в свой мир (лечение и изменения) |
| world_snapshot_view(view) → PackedByteArray | Весь мир байтами (для входа гостя) |
| install_world(bytes) → bool | Поставить мир из байтов. false, если не получилось (ошибка desync) |
| view_of(seat) → Variant | Ключ «вида» мира для места. Если вид один на всех, верните "" |
| begin_views(tick) | Хост зовёт перед расчётом видов (для кэша). Можно оставить пустым |
| current_day() → int | Текущий день или номер хода мира |
| advance(days, tick, seed) | Режим «все считают»: прожить дни. Случайности берите только из rng(seed, tick, …) |
| action_policy(kind) → String | Кто может отдавать приказ: "own" (владелец места), "free" (любой), "host" (только хост) |
| execute_action(actor, action, ctx) → Dictionary | Исполнить приказ: {ok, code, text}. В режиме «все считают» вызывается у всех |
| apply_event(ev) | Системное событие: место занято, страну ведёт ИИ |
| apply_deal(kind, from, to, terms) → Dictionary | Дело между игроками (сделка, союз, война) исполнено |
| auto_reply(kind, from, to, terms) → bool | Ответ места, которое ведёт ИИ |
| countries() → Array | Места лобби: [{id, name}] |
| on_news, on_ai_result, on_action_result, on_chat, on_notice, on_roster_changed, on_progress, on_time_state | Только показ игроку, на мир не влияют. Могут быть пустыми |
hash(словарь). Он зависит от порядка, в котором добавлялись ключи, и у двух игроков даст разные числа для одинаковых данных. Тогда лечение будет срабатывать без конца. Берите lib.state_hash(v) или сумму по парам, как в примере.Партия в мире самой Pax Universe
В библиотеку входит готовая синхронизация мира стратегии Pax Universe: то, ради чего PaxNet создавался. Это lib.world_service(): сессия с адаптером GameAdapter. Разделами служат ключи записи сейва игры. Мир повёрнут к стране каждого гостя. Владельцы областей лечатся через стабильный API.
| Вызов | Что делает |
|---|---|
world_service().host_open(имя, правила) | Хост открывает партию своего мира и сам занимает свою страну. Шаги мира идут сами по ходу игры (сигнал Pax.world_stepped). |
world_service().host_start() | Гости получают мир в сетевой слот сохранения и открывают его. |
world_service().guest_join(код, имя) | Войти в чужой мир. |
консоль F8: paxnet host Имя, paxnet join КОД Имя, paxnet start, paxnet state, paxnet leave | То же для проверки руками. |
res://src/net/Seats.gd, res://src/save/Save.gd, game.main). В другой версии игры они могут измениться. Тогда world_service() вернёт null и напишет предупреждение в журнал. Остальная библиотека продолжит работать.Свою страну в такой партии ведёт сам игрок, поэтому её разделы (приказы, законы, казна) от хоста не лечатся, иначе откатывались бы действия гостя. Одинаковость держат общие разделы: дата, державы, владельцы областей, армии, войны, хроника, рынок.
Не работает? Частые вопросы
Проверка работает по тем же правилам, что lib.parse_code().
lib == null: библиотека не находится
Проверьте три вещи. 1) Мод «PaxNet Core» включён в лаунчере, и игра после этого перезапущена. 2) В mod.json вашего мода есть "depends": ["paxnet_core"]. 3) Вы берёте узел через Pax.get_tree().root.get_meta("paxnet_lib", null), а не через get_node("/root/PaxNetLib") в самом начале: узел попадает в дерево на кадр позже, а метаданные доступны сразу.
host() вернул "relay" или статус сразу failed
Библиотека не нашла канал ретранслятора игры. Причины: игра старая и в ней нет Network.relay_pipe(); нет интернета; ретранслятор временно недоступен. Напечатайте lib.pipe_detail(): там написано, что нашлось вместо канала. Хост ждёт комнату 20 секунд, гость входит 10 секунд.
Код есть, но друг получает «Такого кода нет»
Комната живёт, пока хост в игре и не вызвал leave(). Проверьте код в поле выше: в кодах нет 0, O, 1, I. Если хост перезапускал игру, код мог смениться: попросите новое приглашение.
Сообщение отправлено, но сигнал message не срабатывает
1) Себе сообщения не приходят: хост не получит свой broadcast. 2) Сигнал подключён к той же сессии, через которую идёт игра? Частая ошибка: дважды вызвать new_session() и подключить сигнал ко второй. 3) Сравниваете type с той же строкой, с которой отправляли? "my_mod.chat" и "my_mod_chat" являются разными типами. 4) Гость отправил до входа в комнату: такое сообщение не теряется, оно уйдёт после входа.
Пришёл null вместо моих данных
Вы отправили то, что по сети не ходит: объект, узел, ресурс, Callable, Rect2, Transform. Смотрите таблицу «Что можно отправлять»: разложите данные на числа, строки, массивы и словари.
Ошибка компиляции «Cannot infer the type…»
Игра запрещает :=, если справа значение без типа: результат call(), элемент словаря или массива. Пишите тип явно: var net: Node = lib.new_session(), var hp: int = data["hp"].
Мод не запускается: проверка песочницы ругается на preload
Нельзя делать preload("res://mods/paxnet_core/…") из своего мода: песочница пускает загрузку только по путям своего мода. Берите всё через узел библиотеки: lib.script_of("Bytes"), lib.encode() и так далее.
У друга пропал интернет на минуту. Что будет?
Статус станет reconnecting. PaxNet сам войдёт в комнату снова, до 8 попыток с нарастающей паузой. Игрок вернётся под тем же номером, а всё, что ему отправили за это время, дойдёт ровно один раз и по порядку. Хост в это время видит у игрока away = true. Если вернуться не вышло, статус станет failed и появится кнопка «Войти снова» (rejoin()).
Друг закрыл игру и зашёл снова. Он вернётся на своё место?
Да, если он войдёт по тому же коду с того же компьютера. Хост узнаёт его по ключу игрока. Он получит тот же номер и место, а если есть мир, то и мир: целиком или только пропущенные шаги.
Сколько игроков можно?
По умолчанию 5 человек вместе с хостом (max_players). Технически больше, но каждое сообщение идёт через хоста, и при 10–16 игроках уже важна частота сообщений.
Можно ли играть без интернета, по локальной сети?
Нет. Песочница модов не разрешает свои сокеты, поэтому вся связь идёт через ретранслятор игры. Нужен интернет у всех игроков.
Как сделать окно лобби со списком игроков?
Подключитесь к сигналу changed и в обработчике перерисуйте список из net.players(): имя, ready, ping, away. Код для приглашения берите из net.code() или net.invite_link(), а кнопка «Начать» у хоста вызывает net.start().
Песочница: почему всё устроено именно так
Pax Universe проверяет каждый мод перед запуском и не запускает мод, который нарушает правила безопасности. Это защищает игроков от вредных модов. PaxNet и ваш мод должны соблюдать одно и то же.
| Нельзя | Почему | Что вместо этого |
|---|---|---|
| Сокеты, HTTP, WebSocket, UPnP | Мод не должен сам ходить в интернет | Канал игры relay_pipe(): PaxNet пользуется только им |
FileAccess, DirAccess, ConfigFile, user:// | Мод не должен трогать файлы игрока | save_data()/load_data(), get_setting()/set_setting() |
ClassDB, Expression, GDScript, str_to_var | Через них можно выполнить чужой код | Обычные вызовы; кодек PaxNet для данных |
preload не своего пути, .. в пути | Мод видит только свою папку | lib.script_of("Имя"), методы библиотеки |
var x := словарь["a"] | Строгая типизация игры: тип из нетипизированного значения не выводится | var x: int = словарь["a"] |
| Текст для игрока в коде | Игра на нескольких языках | data/lang/ru.json, en.json и tr_key() |
Что изменено по сравнению с исходным PaxNet
Исходный PaxNet подменял сетевую службу игры и сам открывал UDP-порт. Внутри песочницы модов это невозможно, поэтому его переделали в библиотеку.
Под песочницу
- Своих сокетов больше нет. Удалены
UdpSocket,WsSocket, UPnP, пробивка NAT и вход поip:порт. Связь идёт каналом игрыNetwork.relay_pipe(), так же, как в doom_2. НовыеPipeHostиPipeGuestдают ядру прежний интерфейс, поэтому сессии, сверка и передача мира не переписывались. - Сетевая служба игры не подменяется. Библиотека не должна молча менять сетевую игру всем, кто её включил, и не может отнять у игры канал, через который ходит сама.
- Все пути заданы полными литералами
res://mods/paxnet_core/…, без... FileAccess.COMPRESSION_ZSTDзаменён числом2(сверено с Godot 4.7).
Прочее
- Общий генератор случайностей смешивает имя потока через FNV-1a, а не
hash(). Результат одинаков на любой версии движка, но последовательности отличаются от старого PaxNet. - Ключи перевода переименованы в
paxnet_…, весь текст вынесен из кода. - Звено поверх надёжного канала реже повторяет пакеты: повтор не раньше 400 мс, сигнал «жив» каждые 500 мс, обрыв через 15 с молчания.
- Хост при уходе прощается со всеми. Гости видят «Хост закрыл партию» и не пытаются вернуться.
MPL 2.0 простыми словами
PaxNet распространяется под Mozilla Public License 2.0. Полный текст лежит в файле LICENSE внутри мода и в репозитории на GitHub, в начале каждого файла кода стоит короткое уведомление.
Можно
Пользоваться библиотекой в своём моде: бесплатном, платном, с открытым или закрытым кодом.
Можно
Копировать и распространять PaxNet, в том числе вместе со своим модом.
Нужно
Если вы изменили файлы PaxNet, изменённые файлы тоже распространяются под MPL 2.0 с открытым кодом.
Не нужно
Открывать код своих файлов. Мод, который только пользуется библиотекой, может иметь любую лицензию.
Нельзя
Убирать уведомления о лицензии из файлов PaxNet.
Это пересказ, а не юридическая консультация. Если это важно, читайте полный текст лицензии.
Протокол
Чтобы пользоваться библиотекой, это знать не нужно. Протокол ядра не менялся (Proto.VERSION = 1). Пакеты звена теперь едут датаграммами канала игры, а не UDP. В канале у хоста номер 1, у гостей 2 и дальше. Для ядра адрес гостя выглядит как pipe#номер.
Пакет звена
0xA7 | conn u32 | flags u8 (бит 0: ack действителен) | seq u16 | ack u16 | ack_bits u32 | записи… | HMAC-SHA256 (8 байт)
запись: 1 reliable: varint id | u8 тип | varint длина | тело
2 slot : u8 тип | varint длина | тело («последнее значение»)
3 oneshot : u8 тип | varint длина | тело (без повторов: куски передач)Подтверждения идут в каждом пакете: последний полученный seq плюс битовая карта ещё 32. Надёжные сообщения доставляются по порядку и ровно один раз. Потерянное шлётся снова: быстро (когда подтверждены пакеты новее) или по таймеру srtt + 4·rttvar. Не больше 6 пакетов за опрос, пакет не больше 1200 байт.
Рукопожатие
гость → HELLO(версия, nonce_c) дополнен до 96 байт
хост → CHALLENGE(nonce_c, окно, cookie) cookie = HMAC(секрет хоста, адрес | nonce_c | окно)
гость → CONNECT(nonce_c, окно, cookie, info) info: имя, ключ игрока, моды, есть ли мир, последний шаг, сумма
хост → ACCEPT(conn_id, welcome) | REJECT(код)
кадр: 0xA6 | тип | тело | CRC32; ключ звена = HMAC(cookie | nonce_c, "pax-link")Звено узнаётся по подписи на любом адресе. Поэтому гость, переоткрывший канал под новым номером, продолжает то же звено без нового рукопожатия.
Сообщения ядра
| № | Сообщение | Кто → кому | Доставка |
|---|---|---|---|
| 10 | LOBBY: лобби целиком | хост → все | надёжно, частями |
| 11 | LOBBY_CMD: место, готовность, имя, правила, старт | гость → хост | надёжно |
| 12 | CHAT | любой → хост → все | надёжно |
| 20 / 21 | ACTION / ACTION_RESULT | гость ↔ хост | надёжно, избыточно; отсев по номеру |
| 30 | CLOSURE: шаг мира | хост → все | надёжно, строго по порядку |
| 31 / 32 / 33 / 34 | REPORT, RESYNC, PATCH, PATCH_BLOB | сверка и лечение | ячейка / надёжно |
| 40–44 | WORLD, XFER_*: передача мира | хост → гость | куски, выборочная досылка, CRC32 |
| 50 / 51 | TIME_CMD / TIME | гость → хост / хост → все | надёжно |
| 60 / 61 | PING / PONG | любой | ячейка |
| 70 / 71 | NOTICE / BYE | хост → гость / любой | надёжно |
| 73 | FRAG: кусок большого сообщения | любой | надёжно, по порядку |
| 80 / 81 | APP / APP_ACK: сообщения модов | любой → хост → адресат | номер, «всё до N», досылка после обрыва |
Шаг мира (CLOSURE)
| Поле | Значение |
|---|---|
t | номер шага, растёт на 1 без пропусков |
n | сколько дней прошло (0 означает шаг только с приказами) |
iv | мс между шагами у хоста: гость выдерживает тот же ритм |
a | приказы по порядку: {p, s, r, c, k, g} |
ev, nw, ai | системные события, новости с номером, итоги нейросети |
h, p | суммы разделов после шага; у шага есть изменения мира |
Как это проверено
32 из 32
скриптов библиотеки компилируются в Godot 4.7 со строгой типизацией, как в игре.
43 из 43
проверок сетевого теста на виртуальном ретрансляторе с задержкой 40 мс.
Что именно проверяет сетевой тест: комната и код; вход по ссылке и по PAX-…; состав у всех; 60 сообщений по порядку и без повторов; личное сообщение A→B только для B; 300 КБ байт в байт; готовность и старт; обрыв канала гостя с возвратом под тем же номером, всё, что слали во время обрыва, дошло ровно раз и по порядку; уход хоста. Для мира: 10 шагов разницей, лечение испорченного раздела без пересылки мира, возврат после перезапуска на то же место, адаптер ScoreWorld с этой страницы. Плюс ошибки входа.
Запустить самому
Тесты лежат в отдельном архиве для разработчиков. Скачайте его и распакуйте так, чтобы получилась папка paxnet-tests; в командах ниже замените /paxnet_core/paxnet-tests на свой путь к ней.
godot --headless --path /paxnet_core/paxnet-tests res://tests/compile.tscn # компиляция всех скриптов
godot --headless --path /paxnet_core/paxnet-tests res://tests/net.tscn # сетевой тест
python3 /paxnet_core/paxnet-tests/sandbox_scan.py /paxnet_core/paxnet-tests/mods/paxnet_core paxnet_core # сканер песочницыЧто проверить в самой игре
godot --headless --path <папка игры> -- --check-mods paxnet_core: убедиться, что настоящая проверка согласна со сканером.- Что в вашей версии игры есть
Network.relay_pipe(). Если нет,lib.pipe_detail()и журнал ([paxnet] relay_pipe not found) покажут, что нашлось. - Партию в мире игры: консоль F8,
paxnet host Имяна одном компьютере иpaxnet join КОД Имяна другом.
Помочь развитию PaxNet
PaxNet бесплатный и открытый, и таким останется. Если библиотека пригодилась вашему моду или вы просто хотите, чтобы она росла, можно поддержать разработку. Эти деньги покрывают время на новые возможности, проверку на свежих версиях игры и эту документацию.
What is this, anyway
Think of a post office. One player (the host) opens a "post office" (a room) and gets its address: a short code such as D3EF5YXQ. Friends (the guests) enter that code and arrive at the same office. From then on anyone can send a "letter" (a message) to the host, to a specific friend, or to everyone at once.
PaxNet makes sure every letter arrives exactly once and in the order it was sent. Even if a friend loses internet for a minute, the letters sent in the meantime arrive after they come back. None get lost and none arrive twice.
Letters do not travel directly between computers. They go through the game relay, the server mp.paxuniverse.ru that Pax Universe itself already uses. So there is no need to open ports on a router, know a friend's IP address or set up a VPN.
For players
Enable the mod in the mod list. Nothing else to do: other mods use it.
For mod authors
Call host() or join(), send messages with send() and receive them with the message signal.
For advanced users
Whole-world sync: per-section checksums, healing divergences, handing over the world without pausing the game.
Words you will meet below
Read this once and everything after will make sense. Every term is explained in plain language, with an example.
Host
The player who opened the room. On the network the host is always number 1. The host is in charge: settles disputes and broadcasts the "truth".
Guest
A player who entered someone else's room with a code. Guests are numbered 2, 3, 4… in order of arrival.
Room
A place on the relay where the host and guests meet. It exists while the host is in the game.
Room code
4 to 12 characters with no look-alike letters (no 0, O, 1, I). For example D3EF5YXQ. Case does not matter.
Invite
A link with the code inside: https://mp.paxuniverse.ru/j/CODE. You can send it to a friend in a messenger.
Relay
The game server all messages go through. Pax Universe itself provides it via Network.relay_pipe().
Game / session
Your whole online game: room, players, messages. In code it is the Session node.
Message
Any plain data with a "type", a string that tells you what arrived. For example type "my_mod.chat", data "Hello!".
Exactly once
A message is never lost and never arrives twice, even across a dropped connection. Order is preserved.
Reconnect
The connection dropped and came back by itself. The player returns with the same number to the same place.
Player key
A secret string stored on the player's computer. The host uses it to recognise a returning player.
Like an office pass.Lobby
The time before the game starts: players gather, pick seats, press "ready".
Seat
The number of the country or side a player controls. One seat has one player, but may have co-rulers.
Adapter
Your object through which PaxNet learns about your mod's "world". Only needed if you sync a whole world.
World section
A named piece of the world: "score", "map". Checked and healed separately from the rest.
Checksum (hash)
A short number computed from data. If two players' data differ, their checksums almost certainly differ too.
A fingerprint of the data.Healing
A guest's section diverged from the host's. The guest quietly asks for that section only, and the host sends the correct one.
World step
One "tick" of the game: a number, orders, how many days passed. Steps go strictly in order: 1, 2, 3…
How to install it when a mod asks for PaxNet
Download PaxNet Core from the Workshop
Open the Pax Universe launcher → Workshop, find "PaxNet Core" and click "Download".
Enable it in the "Mods" section
Launcher → Mods → tick "PaxNet Core" and the mod that asked for it. The game applies mods on the next launch.
Your first network mod, step by step
hello host and gets a code, the other types hello join CODE. After that hello say Hi! shows the message to everyone. It is under 90 lines of code, and every piece has a comment.All code on this page is tested: it compiles in Godot 4.7 with strict typing, as in the game, and passes the sandbox check.
Create the mod folder
In the game's
modsfolder, create a folderhello_net. Folder name = mod id: Latin letters, digits and_. Here is what it will contain at the end:mods/ └── hello_net/ ├── mod.json ← the mod's passport: name, version, dependencies ├── main.gd ← mod code, first line: extends PaxMod └── data/ └── lang/ ├── ru.json ← Russian texts └── en.json ← English textsWrite mod.json, the mod's passport
The key line is
"depends": ["paxnet_core"]. It tells the game: "this mod does not work without PaxNet, load the library before me".{ "api": 1.0, "id": "hello_net", "name": {"en": "Hello Net", "ru": "Привет, сеть"}, "description": {"en": "Smallest PaxNet example: chat between players.", "ru": "Самый маленький пример PaxNet: чат между игроками."}, "version": "1.0.0", "entry": "main.gd", "game_version": ">=0.9.0", "authors": ["You"], "depends": ["paxnet_core"], "load_after": ["paxnet_core"], "tags": [] }Common mistakeForgettingdepends. Then your mod may load before the library and fail to find it.Add texts to data/lang
In Pax Universe you may not write player-facing text directly in code: everything the player sees lives in translation files (a game rule). In code you write a key, for example
tr_key("hello_net_me"), and the game substitutes the text in the player's language. Start keys with your mod id so they do not clash with other mods.{ "hello_net_no_lib": "Enable the PaxNet Core mod and restart the game.", "hello_net_me": "Player", "hello_net_wait": "Opening the room… In a couple of seconds type: hello code", "hello_net_line": "%s: %s", "hello_net_joined": "%s joined the game", "hello_net_help": "hello host | hello code | hello join CODE | hello say TEXT | hello leave" }The
ru.jsonfile has the same keys in Russian. At least these two languages are required.{ "hello_net_no_lib": "Включите мод PaxNet Core и перезапустите игру.", "hello_net_me": "Игрок", "hello_net_wait": "Комната открывается… Через пару секунд наберите: hello code", "hello_net_line": "%s: %s", "hello_net_joined": "%s вошёл в партию", "hello_net_help": "hello host | hello code | hello join КОД | hello say ТЕКСТ | hello leave" }Write main.gd, the mod itself
The first line is always
extends PaxMod. Read the comments: they say what each piece does.extends PaxMod ## The smallest network mod on PaxNet: chat between players via the console (F8). ## Our message type. Start it with your mod id so types from different mods never mix. const MSG_SAY: String = "hello_net.say" ## The online game (the net/Session.gd node from the library). null if not created yet. var net: Node = null func _mod_loaded() -> void: Pax.register_command("hello", Callable(self, "_cmd"), tr_key("hello_net_help")) ## The PaxNet library: the node it stores in the root's metadata. null if the library is not enabled. func _lib() -> Node: var v: Variant = Pax.get_tree().root.get_meta("paxnet_lib", null) return v as Node if v is Node else null ## The session is created once and lives as long as the game runs. func _net() -> Node: if net != null and is_instance_valid(net): return net var lib: Node = _lib() if lib == null: return null net = lib.call("new_session") net.connect("message", _on_message) net.connect("event", _on_event) net.connect("failed", _on_failed) return net func _cmd(args0: Variant = []) -> String: var args: Array = [] if args0 is String: args = Array((args0 as String).split(" ", false)) elif args0 is Array: args = args0 var n: Node = _net() if n == null: return tr_key("hello_net_no_lib") var what: String = str(args[0]) if args.size() > 0 else "" match what: "host": var err: String = str(n.call("host", tr_key("hello_net_me"))) return err if err != "" else tr_key("hello_net_wait") "code": return str(n.call("invite_link")) "join": if args.size() < 2: return tr_key("hello_net_help") var err2: String = str(n.call("join", str(args[1]), tr_key("hello_net_me"))) return err2 if err2 != "" else "ok" "say": var text: String = " ".join(PackedStringArray(args.slice(1))) n.call("broadcast", MSG_SAY, text) return tr_key("hello_net_line") % [tr_key("hello_net_me"), text] "leave": n.call("leave") return "ok" return tr_key("hello_net_help") ## A message arrived from another player. func _on_message(from_id: int, type: String, data: Variant) -> void: if type == MSG_SAY: var who: String = str(net.call("player_name", from_id)) _toast(tr_key("hello_net_line") % [who, str(data)]) ## Session events: here only "someone joined" (the host sees it). func _on_event(kind: String, data: Dictionary) -> void: if kind == "peer_joined": _toast(tr_key("hello_net_joined") % str(data.get("name", "?"))) ## Error: show a clear reason from the library's translations. func _on_failed(code: String) -> void: _toast(tr_key("paxnet_err_" + code) + " " + tr_key("paxnet_err_" + code + "_do")) func _toast(text: String) -> void: var g: Variant = Pax.get("game") if g is Object and (g as Object).has_method("toast"): (g as Object).call("toast", text) else: print(text)What matters here_lib()fetches the library.new_session()creates a session.host()opens a room,join()enters one.broadcast()sends to everyone. Themessagesignal fires when a message reaches you.Check the mod
Run this command in the game folder. Exit code
0means no errors:godot --headless --path <game folder> -- --check-mods hello_netEnable both mods and run the game on two computers
Launcher → Mods → tick "PaxNet Core" and "Hello Net". Restart.
Play
Press F8 to open the console.
Who What to type What happens Host hello host"Opening the room…". The room is ready in 1–2 seconds. Host hello codeAn invite link, for example https://mp.paxuniverse.ru/j/D3EF5YXQ. Send it to a friend.Friend hello join D3EF5YXQThe friend enters the room; the host sees "Player joined the game". Pasting the whole link works too. Anyone hello say Hi!Everyone else sees "Player: Hi!". Anyone hello leaveLeave. If the host leaves, guests see "The host closed the game". DoneThis is a complete online game: dropped connections, ordering and duplicates are already handled for you. Next come the reference and the recipes: real-time movement, turn-based play, shared dice, a room of the day.
How a single message travels
A guest called send(1, "my_mod.chat", "Hi"). Here is what happens next, top to bottom. You do not need to know this, but it makes errors easier to understand.
The message gets a sequence number: 1, 2, 3… The receiver uses these numbers to drop duplicates and restore order.
The data is turned into bytes by PaxNet's own codec. Anything over 4 KB is compressed. A large message is split into chunks.
Chunks are packed into packets of up to 1200 bytes. Each packet is signed with a key only this guest and the host know. Packets cannot be forged.
The packet goes into the relay channel, addressed to the host, number 1.
The Pax Universe server forwards the packet to the host over a secure connection (wss, port 443).
The host checks the signature, reassembles the chunks, decompresses. It acknowledges: "received everything up to number N".
If the message was for the host, the host's message signal fires. If it was for another guest or for everyone, the host forwards it the same way and states the real author itself.
Who can message whom
| Call | Who receives it | from_id at the receiver |
|---|---|---|
send(1, …) on a guest | Only the host | the guest's number |
send(3, …) on guest #2 | Only guest #3 (via the host) | 2 |
send(0, …) or broadcast(…) on a guest | The host and all guests except the author | the author's number |
send(2, …) on the host | Only guest #2 | 1 |
broadcast(…) on the host | All guests | 1 |
send(own_number, …) | Nobody: you never receive your own messages | none |
Status: where your game is right now
A session has a status field. It changes by itself, and every change fires the changed signal. Look at the status to decide what to show the player.
connectingOpening or entering the room. Usually 1–2 seconds.
lobbyEveryone is in the room; the game has not started. Messages already flow.
syncingA guest is receiving the world from the host. The host's game keeps running.
playingThe game is on.
reconnectingThe connection dropped; PaxNet brings the player back by itself. Nothing to do.
failedThe connection did not come back. Show the player a "Rejoin" button (rejoin()).
closedThe host closed the game or kicked the player. Nowhere to return to.
There is also "" (empty string): no game; the session was just created or left with leave().
syncing, and playing starts after start(). Messages already work in lobby: if your mod has no "game start", you do not need to call start() at all.PaxNetLib: the library's entry point
The library creates the /root/PaxNetLib node and immediately, before it even enters the tree, stores it in the root's metadata. So fetch it like this; it works even in _mod_loaded:
## Fetch the library. Works already in _mod_loaded: the node lives in the root's metadata.
func get_paxnet() -> Node:
var v: Variant = Pax.get_tree().root.get_meta("paxnet_lib", null)
if v is Node:
return v as Node
return null # library not enabled: show the player a hintload() and preload() only for paths of your own mod. A line like preload("res://mods/paxnet_core/…") in your mod will be rejected. Everything you need is handed out by the library node itself.| Method | What it does | Example |
|---|---|---|
| new_session(parent = null) → Node | Creates a new online game (Session) and adds it to the tree. The player key is filled in automatically. | var net: Node = lib.new_session() |
| open_pipe(first) → Object | A raw relay channel for your own protocol (as in doom_2). See recipe 6. | lib.open_pipe({"op": "join", "code": c}) |
| overlay(session, parent) → CanvasLayer | A status line at the top of the screen for the session: code, players, ping, a clear error with a button. | lib.overlay(net, game.hud_layer()) |
| encode(value) → PackedByteArray | Value to bytes. Custom codec: objects are never encoded, depth and lengths are limited. | var b: PackedByteArray = lib.encode({"hp": 5}) |
| decode(bytes) → Dictionary | Bytes to value: {ok, v}. Garbage does not crash the game; you get ok = false. | var d: Dictionary = lib.decode(b) |
| rng(seed, tick = 0, stream = "") → RefCounted | A random generator identical for all players. Methods: frand(), irange(a, b), chance(p), pick(arr), next_u32(). | lib.rng(seed, turn, "my_mod/dice") |
| state_hash(value) → int | A 32-bit fingerprint of a value, independent of dictionary key order. For sync checks. | lib.state_hash(world) |
| parse_code(text) → String | The room code from whatever the player typed: a code, PAX-…, a link. "" means it is not a code. | lib.parse_code("pax-d3ef5yxq") |
| invite_link(code) → String | Invite link for a code. | lib.invite_link("D3EF5YXQ") |
| shared_code(seed, stream, prefix = "") → String | A room code that is the same for everyone with the same seed and stream. 8 characters total; prefix is up to 4 characters at the start. | lib.shared_code(lib.utc_day(), "my_mod/daily", "MM") |
| utc_day() → int | The current UTC day number: the same for every player in the world. | lib.utc_day() |
| player_key() → String | The persistent player key on this computer (shared by all mods). | lib.player_key() |
| script_of(name) → Script | A library script by name: Proto, Bytes, SharedRng, StateHash, Delta, Crc32, Hmac, Adapter, BasicAdapter, HostSession, GuestSession, Mailbox, TimeAuthority, Relay, RelayPipe, PipeHost, PipeGuest, Session, NetOverlay. | var RP: Script = lib.script_of("RelayPipe") |
| world_service() → Node | A game in the world of Pax Universe itself (the strategy game). null if unavailable in this game version. | lib.world_service().host_open("Anna") |
| world_service_if_any() → Node | Same, but does not create it: null if it does not exist yet. | |
| pipe_detail() → String | What was found instead of the channel on the last failure. For logs and support. | print(lib.pipe_detail()) |
Call methods with lib.call("name", …) or simply lib.name(…): GDScript allows calling by name on a Node. The result of such a call is untyped, so give the variable an explicit type: var net: Node = lib.new_session(). The game will not compile var net := lib.new_session().
Session: your online game
The node returned by lib.new_session(). It is already in the tree and keeps processing even while the game is paused. One session is one game; after leave() the same session can be reused.
Tags: host works only on the host, guest only on a guest, both on everyone.
Open, join, leave
| Method | Who | What it does | Example |
|---|---|---|---|
| host(name, adapter = null, cfg = {}) → String | host | Open a room. name: the player name everyone will see. adapter: only for world sync, otherwise null. cfg: room settings. Returns "" if opening has started, or an error code. The room code appears in 1–2 seconds (the room event, status lobby). | net.host("Anna") |
| join(target, name, adapter = null, extra = {}) → String | guest | Join by code, "PAX-…" or link. Returns "" (joining, wait for status lobby) or an error code: "code" if the input is clearly not a code, "relay" if the relay channel did not open. If no room has that code, you find out later: the failed("code") signal fires. extra.mods is compared with the host's cfg.mods. | net.join("D3EF5YXQ", "Boris") |
| start() → Dictionary | host | Start the game: status playing, guests receive the world (or an empty world if there is no adapter). Returns {ok, code}. Optional if your mod has no "start". | net.start() |
| leave() | both | Leave. A guest says goodbye to the host. The host says goodbye to everyone and closes the room. Called automatically when the node is freed. | net.leave() |
| rejoin() → String | guest | Join again after a connection error (status failed): same key, same seat. | net.rejoin() |
| request_full_world() | guest | Ask for the entire world if it diverged and did not heal (desync error). |
Messages
| Method | Who | What it does | Example |
|---|---|---|---|
| send(to, type, data = null) → bool | both | Send a message. to: 1 (host), 2… (guest), 0 (everyone except yourself). Delivered exactly once and in order. Returns false if there is no game. | net.send(1, "my_mod.hi", "hello") |
| broadcast(type, data = null) → bool | both | Same as send(0, …): everyone except yourself. | net.broadcast("my_mod.pos", Vector2(3, 4)) |
| action(kind, args = null) → Variant | both | An order via a world step: same order for everyone. Only needed with an adapter. Works only after start(): in the lobby the order is rejected with illegal. If the adapter's action_policy returns "own" (the default), the author must hold a seat. A guest gets the order number; the host gets {ok, code}. | net.action("my_mod.build", {"x": 2}) |
Lobby
| Method | Who | What it does | Example |
|---|---|---|---|
| set_ready(on) | both | The "ready" mark. | net.set_ready(true) |
| choose_seat(seat, as_coruler = false) | both | Take a seat (country, side) by number from the adapter's countries(). If the seat is taken nothing changes: check players() after the changed signal. On the host, lobby_cmd returns {ok: false, code: "seat_taken"}. | net.choose_seat(0) |
| chat(text) | both | A chat line to everyone (up to 300 characters). Arrives in the adapter's on_chat. For your own chat, send/broadcast is simpler. | net.chat("gg") |
| kick(id) | host | Kick a player: they see "The host removed you from the game". | net.kick(3) |
| lobby_cmd(cmd) → Variant | both | Any lobby command: {op: "seat"|"ready"|"name"|"rules"|"kick"|"start", …}. rules is host-only: time_mode, join_in_progress, ready_required, allow_coruler, max_players. | net.lobby_cmd({"op": "name", "name": "Anna"}) |
Game time
| Method | Who | What it does | Example |
|---|---|---|---|
| time_cmd(kind, value = null) | both | kind: "pause", "resume", "speed" (value from 1 to 5). With time_mode "host" the host refuses a guest: code host_only goes to the adapter (on_notice). With "vote" a vote starts. | net.time_cmd("pause") |
| time_state() → Dictionary | both | {paused, speed, by, by_name, mode, votes, v}: whether paused, who paused, whether a vote is on. |
Querying state
| Method | Who | What it does | Example |
|---|---|---|---|
| is_host() / is_guest() → bool | both | Who am I in this game. | |
| active() → bool | both | In a game and connected: status lobby, syncing or playing. | |
| my_id() → int | both | My number: 1 on the host, 2… on a guest. 0 if the guest has not joined yet. | |
| code() → String / invite_link() → String | both | Room code and invite link. | |
| players() → Array | both | All players: [{id, name, seat, role, ready, ai, ping, host, away}]. away: connection lost, seat is held. | |
| player_name(id) → String | both | Player name by number ("?" if not found). | |
| rtt_ms() → float | guest | Ping to the host in milliseconds. | |
| state() → Dictionary | both | Everything at once: role, status, code, players, time, notice, rtt, id. Handy for your own lobby window. |
Signals
| Signal | When it fires |
|---|---|
| message(from_id: int, type: String, data: Variant) | A message arrived. from_id: the author's number (1 means the host). The main signal. |
| changed() | Something changed: status, roster, readiness, time, an error. Handy for redrawing a lobby window. |
| failed(code: String) | The game failed or was closed. code comes from the error table. |
| event(kind: String, data: Dictionary) | Detailed events, listed below. |
Events (event)
| kind | On | Meaning | data |
|---|---|---|---|
room | host | Room opened, code ready | {code} |
reopening | host | The relay lost the room; reopening it with the same code | {try} |
relay_error | host | The relay did not give a room | {code, why} |
peer_joined | host | A player joined or returned | {id, name, reconnect, mode} |
peer_left | host | A player left or vanished (their seat is held) | {id, why}; why: bye, timeout, kicked |
lobby | both | The lobby was updated | the whole lobby state |
started | host | The game started | {} |
world_sent / world_delivered | host | World sent to a guest / the guest assembled it | {id, …} |
step | host | A world step was made | {t, n} |
connected | guest | Connected to the host (also after a return) | {id, mode, reconnect} |
lost | guest | Connection lost, starting to come back | {} |
world_installed | guest | The host's world was received and installed | {tick} |
patched / mismatch | guest | World changes arrived / a divergence was found (will be healed) | {names, tick} |
action_rejected | guest | The host rejected an order | {c, ok, code} |
closed / failed | guest | The host closed the game / the connection did not return | {why} / {code} |
Readable fields
| Field | Contents |
|---|---|
role | "host", "guest" or "" |
status | See status |
last_notice | The last error: {what, do, code, args}. what and do are translation keys: tr_key(n["what"]) |
player_key | The player key. Issued by the library, shared by all mods |
defaults | Default room settings (see below) |
session, transport, adapter | Internals (HostSession/GuestSession, PipeHost/PipeGuest, your adapter), for advanced use |
Room settings
The third argument of host(name, adapter, cfg) is a dictionary. Everything is optional: any key you omit takes its default.
net.host("Anna", null, {"max_players": 4, "time_mode": "vote", "join_in_progress": false})| Key | Default | Meaning |
|---|---|---|
max_players | 5 | How many people in the game including the host. The sixth gets the full error. |
join_in_progress | true | Whether people can join a game in progress. With false newcomers get started (players returning with their key always get in). |
time_mode | "host" | Who controls time. "host": only the host. "vote": voting (anyone can pause immediately; unpausing or changing speed needs a majority within 15 seconds). |
allow_coruler | true | Whether two people can play one seat together (co-ruler). |
ready_required | false | start() will not work until everyone pressed "ready" (returns {ok: false, code: "not_ready"}). The library has no ready paxnet_err_… text for not_ready: write the player message yourself. |
room_name | host's name | Room name on the relay. |
public | false | Ask the relay to show the room in the public list. Joining by code always works. |
preset | "paxnet" | Room label for the relay. Set it to your mod id. |
want_code | "" | Request a specific code (continuing a saved game, room of the day). If the code is taken, the relay may give another one. Check code() after the room event. |
mods | "" | A mod-set "fingerprint" string. If not empty, a guest with a different string in join(…, {"mods": …}) gets the mods error. |
authority | false | For world sync: with true only the host computes the world and broadcasts the changes. See your own world. |
external_time | false | With true you make world steps yourself by calling session.record_step(days), instead of the speed timer. Before start() the call does nothing. |
push_min_ms | 0 | In authority mode: minimum milliseconds between world-change broadcasts. |
What you can send
Put only plain values in data. Objects (nodes, resources, scripts) never travel over the network: that way another player cannot send you a malicious object.
| Type | Allowed? | Note |
|---|---|---|
null, bool, int, float, String | ✅ | StringName arrives as String |
Array, Dictionary | ✅ | Nesting up to 48 levels. Dictionary keys must be plain values too |
PackedByteArray | ✅ | Sent as is: handy for your own binary formats |
PackedInt32/64Array, PackedFloat32/64Array, PackedStringArray | ✅ | Arrive as a plain Array |
Vector2, Vector3, Color, Vector2i | ✅ | Vector2/3 and Color are sent with float32 precision |
Rect2, Transform, Quaternion and others | ❌ arrives as null | Break them into numbers or arrays |
Object, Node, Resource, Callable | ❌ arrives as null | Send an id or a name, not the object itself |
Limits
| What | Limit | What happens above it |
|---|---|---|
| One message after compression | ≈ 4.5 MB (4096 chunks of ~1.1 KB) | Not sent. Split it yourself |
| Compression | Turns on automatically for messages from 4 KB | nothing |
| Message type | Any string; the convention is "mod_id.what" | nothing |
An action() order | up to 700 bytes, kind up to 40 characters, ~20 per second (burst 40) | Rejected: illegal or rate |
Chat chat() | 300 characters, about 6 lines in a row | Line dropped, the author gets rate |
send() rate | No hard limit | 10–20 times per second per player is fine; 60 times per second with large messages is already a lot |
if data is Dictionary:, if data is Vector2:. Do not trust numbers blindly: check that a coordinate is inside the map and a count is not negative.Errors and what to do about them
An error arrives as a string code: from host() or join() (if it failed immediately), in the failed(code) signal and in last_notice.code. For every code the library ships two ready texts in Russian and English: paxnet_err_CODE (what happened) and paxnet_err_CODE_do (what to click).
func _on_failed(code: String) -> void:
var what: String = tr_key("paxnet_err_" + code) # "No such code, or the invite has expired."
var todo: String = tr_key("paxnet_err_" + code + "_do") # "Check the code with your friend…"
game.toast(what + " " + todo)code, full, relay, timeout, host_left, kicked, version, mods, started, no_route, desync) arrive in the failed signal. Refusals of individual actions (illegal, host_only, rate) do not break the game: they arrive in your adapter's on_notice method and in the action_rejected event.| Code | When it happens | What the player sees | What to do |
|---|---|---|---|
version | The guest has a different PaxNet protocol version than the host. | Your online version differs from the host's. | Update the game to the host's version and join again. |
mods | The host set cfg.mods and the guest's mods string differs. | Your mod set does not match the host's. | Enable the same mods as the host and join again. |
full | The game (including the host) already has max_players people, or the relay said the room is full. | There are no free spots in the game. | Ask the host to free a spot. |
code | The input is not a code (wrong characters, length) or no such room exists. | No such code, or the invite has expired. | Check the code with your friend or ask for a new invite. |
banned | Reserved: the library does not issue this code at the moment. | The host has barred you. | Message the host. |
started | The game is running and the host has join_in_progress = false. Players returning with their key always get in. | The game is already on; the host's rules close entry. | Ask the host to allow joining a game in progress. |
seat_taken | choose_seat on a seat that already has a player. | Another player already controls this country. | Pick another country or become a co-ruler. |
timeout | No packets from the host for more than 15 seconds and returning failed. | No word from the host for a while: the connection is lost. | The game tries to return by itself. You can click "Rejoin": you will return to your country. |
kicked | The host called kick(id). | The host removed you from the game. | Message the host. |
host_left | The host left (leave) or their room no longer exists on the relay. | The host closed the game. | Wait until the host starts it again and join with the same code. |
illegal | An order or command against the rules: wrong phase, no seat, order kind too long. | Order rejected: that is not allowed. | Read the reason in the order window and fix the order. |
host_only | A guest tries to do something only the host may: move time with time_mode "host", change rules. | The host controls time in this game. | Ask the host to pause or change speed. |
rate | Too often: more than ~40 orders in a row or ~6 chat lines within a few seconds. | Too many commands in a row. | Wait a second. |
bad_packet | Reserved: corrupted packets are dropped silently. | Corrupted data arrived. | If it happens again, rejoin. |
desync | The world could not be installed: the adapter's install_world returned false. | Your world diverged from the host's and did not heal by itself. | Click "Rejoin" and the world will arrive again in a few seconds. |
no_route | The handshake with the host did not complete within 8 seconds. | The host is not responding. | Click "Rejoin": the game will enter the room via the relay once more. |
relay | No Network.relay_pipe() in the game, or the relay refused or did not respond (the host waits 20 s, a guest 10 s). | The Pax Universe relay is unavailable. | Check your internet and game version and try again in a minute. |
Ready-made solutions: copy and adapt
In the examples lib is the library and net is a session from lib.new_session(). Message types start with my_mod.: replace it with your mod id.
1. Real-time player positions
Each player tells everyone where they are 10 times per second. That is enough for smooth motion: between messages, smooth the position locally, for example with lerp.
const MSG_POS: String = "my_mod.pos"
var _pos_timer: float = 0.0
## Where the other players are now: player id → position.
var others: Dictionary = {}
## Call every frame from your _process: broadcasts your position 10 times per second.
func send_my_position(delta: float, my_pos: Vector2) -> void:
_pos_timer += delta
if _pos_timer < 0.1 or not bool(net.call("active")):
return
_pos_timer = 0.0
net.call("broadcast", MSG_POS, my_pos) # to everyone except yourself
## From the message signal handler.
func on_position(from_id: int, data: Variant) -> void:
if data is Vector2:
others[from_id] = data2. Turn-based play: the host is the judge
The most robust scheme for turn-based games and anything where cheating is possible. A guest only asks to make a move. The host checks the rules and broadcasts the new state. Guests trust only the host.
const MSG_MOVE: String = "my_mod.move" # guest → host: "I want to move"
const MSG_STATE: String = "my_mod.state" # host → all: "here is the board now"
var board: Dictionary = {"turn": 0, "cells": {}}
## The player clicked a cell.
func try_move(cell: Vector2i) -> void:
if bool(net.call("is_host")):
_judge(1, cell) # the host judges its own moves too
else:
net.call("send", 1, MSG_MOVE, cell) # a guest only asks
func on_judge_message(from_id: int, type: String, data: Variant) -> void:
if type == MSG_MOVE and bool(net.call("is_host")) and data is Vector2i:
_judge(from_id, data)
elif type == MSG_STATE and from_id == 1 and data is Dictionary:
board = data # a guest trusts only the host
func _judge(player_id: int, cell: Vector2i) -> void:
var cells: Dictionary = board["cells"]
var key: String = "%d,%d" % [cell.x, cell.y]
if cells.has(key):
return # cell taken: move rejected
cells[key] = player_id
board["turn"] = int(board["turn"]) + 1
net.call("broadcast", MSG_STATE, board) # all guests get the new board3. Shared dice: randomness that is the same for everyone
A plain randi() gives each player something different. lib.rng(seed, step, "stream") gives everyone the same result when all three inputs match: on any computer and any engine version.
## A die that rolls the same for every player. The host hands out seed once at the start
## of the game (for example in a "my_mod.seed" message); turn is the turn number.
func roll_dice(seed: int, turn: int) -> int:
var r: RefCounted = lib.call("rng", seed, turn, "my_mod/dice")
return int(r.call("irange", 1, 6))Generator methods: next_u32(), frand() (0 to 1), irange(a, b) (an integer from a to b inclusive), chance(p), pick(array).
4. Room of the day
Everyone playing today lands in the same room without exchanging codes: the code is computed from the day number.
## Room of the day: every player gets the same code today. The first to arrive opens it,
## the rest join.
var _daily_code: String = ""
func enter_daily_room(my_name: String) -> void:
_daily_code = str(lib.call("shared_code", int(lib.call("utc_day")), "my_mod/daily", "MM"))
net.connect("failed", _on_daily_failed, CONNECT_ONE_SHOT)
net.call("join", _daily_code, my_name)
func _on_daily_failed(code: String) -> void:
if code == "code":
# no such room yet: open it ourselves with this code
net.call("host", "Daily", null, {"want_code": _daily_code, "max_players": 16})5. On-screen status line
One line at the top of the screen with the code, players and ping. On an error a window appears with an explanation and a "Rejoin" or "OK" button. The library takes the texts from its own translations.
## Status line at the top of the screen: code, players, ping, a clear error with a "Rejoin" button.
func show_status_line(game: PaxGame) -> void:
lib.call("overlay", net, game.hud_layer())6. Raw channel: your own protocol end to end
If PaxNet sessions do not suit you (doom_2 is built this way), use the relay channel directly. Then ordering, duplicates and reconnection become your job. The game channel is already reliable and ordered, but on a disconnect everything in flight is lost.
## Raw relay channel without PaxNet sessions: the whole protocol is on you
## (doom_2 is built this way). In the channel the host is 1, guests are 2, 3…; 0 means "all guests".
var pipe: Object = null
var RP: Script = null
func open_raw_room() -> void:
RP = lib.call("script_of", "RelayPipe")
pipe = lib.call("open_pipe", {"op": "host", "name": "my room", "public": false, "max": 4,
"ai": "", "preset": "my_mod", "host_name": "Host", "code": ""})
if pipe == null:
push_warning("relay: " + str(lib.call("pipe_detail")))
return
RP.call("wire", pipe, self, "_raw_") # channel signals → _raw_lobby_ready etc.
func _raw_lobby_ready(code: Variant, _rules: Variant = null) -> void:
print("room code: ", code)
func poll_raw() -> void: # every frame
RP.call("poll", pipe)
while true:
var pk: Array = RP.call("recv", pipe) # [sender, bytes] or []
if pk.is_empty():
break
var d: Dictionary = lib.call("decode", pk[1])
if bool(d["ok"]):
print("from ", pk[0], ": ", d["v"])
func raw_send_all(value: Variant) -> void:
RP.call("send", pipe, 0, lib.call("encode", value))Your own world: checks and healing
The idea
The world is split into sections, named dictionaries ("score", "map", "units"). Each section gets a checksum. The host broadcasts its checksums. A guest compares them with its own. If section "map" does not match, the guest quietly asks the host for that section only. The host sends it, the guest inserts it, and the world is identical again; the game never stopped.
Two modes
The host computes the world authority: true
The host changes the world and calls record_step(). Guests receive only the changes (a diff) along with checksums. Guests compute nothing themselves. Suits almost everyone.
Everyone computes the same authority: false
Every step everyone gets identical orders, day count and randomness. Each player recomputes the world, and checksums catch divergences (as in Factorio). Requires strictly deterministic code.
The adapter: your world for PaxNet
The adapter is an object with methods PaxNet calls by name. You do not need to inherit anything from the library (and you cannot: the sandbox rejects a foreign extends). What matters is that all the methods exist. Here is a complete working example: a world with scores and a flag. It is covered by the network test: the world is delivered and changes arrive.
extends RefCounted
## A world for sync checks: player scores and a flag on the map. The PaxNet core calls these
## methods by name, so you do not need to inherit the library's Adapter.gd: it is enough that all methods exist.
## The host computes the world (cfg "authority": true): guests receive changes as diffs and compare checksums.
## World sections: name → plain data (numbers, strings, arrays, dictionaries).
var sections: Dictionary = {"score": {}, "flag": {"owner": "", "x": 0, "y": 0}}
var day: int = 0
## Where the mod reports changes (optional).
var on_changed: Callable = Callable()
# ---------- the world as a set of sections ----------
func section_names() -> Array:
return ["flag", "score"]
func section_period(_name: String) -> int:
return 1
func hashes(_tick: int, _all: bool = false) -> Dictionary:
var h: Dictionary = {}
for n in sections:
h[n] = _hash(sections[n])
return h
func hashes_view(_view: Variant, tick: int, all: bool = false) -> Dictionary:
return hashes(tick, all)
func section_data(names: Array) -> Dictionary:
var out: Dictionary = {}
for n in names:
if sections.has(n):
out[n] = (sections[n] as Dictionary).duplicate(true)
return out
func section_data_view(_view: Variant, names: Array) -> Dictionary:
return section_data(names)
func apply_sections(data: Dictionary) -> void:
for n in data:
sections[n] = data[n]
if on_changed.is_valid():
on_changed.call()
func world_snapshot_view(_view: Variant) -> PackedByteArray:
return var_to_bytes({"s": sections, "d": day})
func install_world(bytes: PackedByteArray) -> bool:
var v: Variant = bytes_to_var(bytes)
if not (v is Dictionary) or not (v as Dictionary).has("s"):
return false
sections = (v as Dictionary)["s"]
day = int((v as Dictionary)["d"])
if on_changed.is_valid():
on_changed.call()
return true
func view_of(_seat: int) -> Variant:
return ""
func begin_views(_tick: int) -> void:
pass
# ---------- game flow ----------
func current_day() -> int:
return day
func advance(days: int, _tick: int, _world_seed: int) -> void:
day += days
func action_policy(_kind: String) -> String:
return "free"
func execute_action(_actor: Dictionary, _action: Dictionary, _ctx: Dictionary) -> Dictionary:
return {"ok": false, "code": "illegal", "text": ""}
func apply_event(_ev: Dictionary) -> void:
pass
func apply_deal(_kind: String, _from_seat: int, _to_seat: int, _terms: Dictionary) -> Dictionary:
return {"ok": true}
func auto_reply(_kind: String, _from_seat: int, _to_seat: int, _terms: Dictionary) -> bool:
return false
func countries() -> Array:
return [{"id": 0, "name": "red"}, {"id": 1, "name": "blue"}]
# ---------- player-facing output (does not affect the world) ----------
func on_news(_e: Dictionary) -> void:
pass
func on_ai_result(_e: Dictionary) -> void:
pass
func on_action_result(_actor: Dictionary, _action: Dictionary, _result: Dictionary) -> void:
pass
func on_chat(_from_id: int, _name: String, _text: String) -> void:
pass
func on_notice(_code: String, _args: Dictionary) -> void:
pass
func on_roster_changed(_players: Array) -> void:
pass
func on_progress(_what: String, _ratio: float) -> void:
pass
func on_time_state(_state: Dictionary) -> void:
pass
# A hash that does not depend on dictionary key order (custom, because hash() of a dictionary does).
static func _hash(v: Variant) -> int:
if v is Dictionary:
var sum: int = 13
for k in v:
sum += hash(str(k)) * 31 + _hash((v as Dictionary)[k])
return sum & 0xFFFFFFFF
return hash(v) & 0xFFFFFFFFHow to use it
var world: RefCounted = load("res://mods/my_mod/ScoreWorld.gd").new()
func host_world() -> void:
net.host("Anna", world, {"authority": true, "external_time": true})
func join_world(code: String) -> void:
var my_world: RefCounted = load("res://mods/my_mod/ScoreWorld.gd").new()
net.join(code, "Boris", my_world) # the guest has its own instance; PaxNet fills it
# The host changed something in the world: record a step, guests receive the changes.
# Works only after net.start(): in the lobby record_step does nothing.
func add_point(player: String) -> void:
var score: Dictionary = world.sections["score"]
score[player] = int(score.get(player, 0)) + 1
var core: Object = net.session
core.record_step(0) # 0 days: a step with changes onlyAdapter methods: what each must do
| Method | What to return or do |
|---|---|
| section_names() → Array | Section names |
| section_period(name) → int | Check the section every N steps (heavy ones less often). Usually 1 |
| hashes(tick, all) → Dictionary | Checksum of each section: {name: number}. It must not depend on key order |
| hashes_view(view, tick, all) | Same for a player's "view". If there is one view for everyone, call hashes |
| section_data(names) → Dictionary | Full copies of sections: {name: data} |
| section_data_view(view, names) | Same for a view |
| apply_sections(data) | Insert received sections into your world (healing and changes) |
| world_snapshot_view(view) → PackedByteArray | The whole world as bytes (for a joining guest) |
| install_world(bytes) → bool | Install the world from bytes. false if it failed (desync error) |
| view_of(seat) → Variant | The "view" key of the world for a seat. If there is one view for everyone, return "" |
| begin_views(tick) | The host calls it before computing views (for caching). May be empty |
| current_day() → int | Current day or world turn number |
| advance(days, tick, seed) | "Everyone computes" mode: simulate the days. Take randomness only from rng(seed, tick, …) |
| action_policy(kind) → String | Who may issue the order: "own" (the seat owner), "free" (anyone), "host" (host only) |
| execute_action(actor, action, ctx) → Dictionary | Execute an order: {ok, code, text}. In "everyone computes" mode it is called on everyone |
| apply_event(ev) | A system event: a seat was taken, a country is run by the AI |
| apply_deal(kind, from, to, terms) → Dictionary | A deal between players (trade, alliance, war) was executed |
| auto_reply(kind, from, to, terms) → bool | The answer of a seat run by the AI |
| countries() → Array | Lobby seats: [{id, name}] |
| on_news, on_ai_result, on_action_result, on_chat, on_notice, on_roster_changed, on_progress, on_time_state | Display only; they do not affect the world. May be empty |
hash(dictionary). It depends on the order in which keys were added and gives two players different numbers for identical data. Then healing will fire endlessly. Use lib.state_hash(v) or a sum over pairs, as in the example.A game in the world of Pax Universe itself
The library includes ready-made world sync for the Pax Universe strategy game, the reason PaxNet was created. It is lib.world_service(): a session with the GameAdapter adapter. Sections are the keys of the game's save record. The world is projected for each guest's country. Province owners are healed through the stable API.
| Call | What it does |
|---|---|
world_service().host_open(name, rules) | The host opens a game of its world and takes its own country. World steps run by themselves as the game proceeds (the Pax.world_stepped signal). |
world_service().host_start() | Guests receive the world into the network save slot and open it. |
world_service().guest_join(code, name) | Join someone else's world. |
console F8: paxnet host Name, paxnet join CODE Name, paxnet start, paxnet state, paxnet leave | The same, for manual testing. |
res://src/net/Seats.gd, res://src/save/Save.gd, game.main). They may change in another game version. Then world_service() returns null and writes a warning to the log. The rest of the library keeps working.In such a game each player runs their own country, so its sections (orders, laws, treasury) are not healed from the host; otherwise the guest's actions would be rolled back. Consistency is kept by the shared sections: date, powers, province owners, armies, wars, chronicle, market.
Not working? Frequently asked questions
The check follows the same rules as lib.parse_code().
lib == null: the library cannot be found
Check three things. 1) The "PaxNet Core" mod is enabled in the launcher and the game was restarted afterwards. 2) Your mod's mod.json has "depends": ["paxnet_core"]. 3) You fetch the node with Pax.get_tree().root.get_meta("paxnet_lib", null), not with get_node("/root/PaxNetLib") at the very start: the node enters the tree one frame later, while the metadata is available immediately.
host() returned "relay" or the status is failed right away
The library did not find the game's relay channel. Causes: the game is old and has no Network.relay_pipe(); no internet; the relay is temporarily down. Print lib.pipe_detail(): it says what was found instead of the channel. The host waits 20 seconds for a room, a guest 10 seconds to join.
There is a code, but my friend gets "No such code"
A room lives while the host is in the game and has not called leave(). Check the code in the field above: codes never contain 0, O, 1, I. If the host restarted the game, the code may have changed: ask for a new invite.
A message was sent, but the message signal does not fire
1) You never receive your own messages: the host does not get its own broadcast. 2) Is the signal connected to the same session the game runs through? A common mistake: calling new_session() twice and connecting the signal to the second one. 3) Do you compare type with the same string you sent? "my_mod.chat" and "my_mod_chat" are different types. 4) A guest sent before entering the room: such a message is not lost, it goes out after joining.
null arrived instead of my data
You sent something that does not travel over the network: an object, node, resource, Callable, Rect2, Transform. See the "What you can send" table: break the data into numbers, strings, arrays and dictionaries.
Compile error "Cannot infer the type…"
The game forbids := when the right-hand side is untyped: a call() result, a dictionary or array element. Write the type explicitly: var net: Node = lib.new_session(), var hp: int = data["hp"].
The mod does not start: the sandbox check complains about preload
You may not preload("res://mods/paxnet_core/…") from your mod: the sandbox allows loading only from paths of your own mod. Get everything through the library node: lib.script_of("Bytes"), lib.encode() and so on.
My friend lost internet for a minute. What happens?
The status becomes reconnecting. PaxNet re-enters the room by itself, up to 8 attempts with increasing pauses. The player returns with the same number, and everything sent to them meanwhile arrives exactly once and in order. Meanwhile the host sees away = true for the player. If returning fails, the status becomes failed and a "Rejoin" button appears (rejoin()).
My friend closed the game and came back. Will they get their seat back?
Yes, if they join with the same code from the same computer. The host recognises them by the player key. They get the same number and seat, and if there is a world, the world too: whole, or only the missed steps.
How many players are possible?
By default 5 people including the host (max_players). Technically more, but every message goes through the host, and with 10–16 players message frequency starts to matter.
Can I play without internet, over a LAN?
No. The mod sandbox does not allow custom sockets, so all traffic goes through the game relay. Every player needs internet.
How do I build a lobby window with a player list?
Connect to the changed signal and in the handler redraw the list from net.players(): name, ready, ping, away. Take the invite code from net.code() or net.invite_link(); the host's "Start" button calls net.start().
The sandbox: why things work the way they do
Pax Universe checks every mod before launch and refuses to run a mod that breaks the security rules. This protects players from harmful mods. PaxNet and your mod must follow the same rules.
| Not allowed | Why | Use instead |
|---|---|---|
| Sockets, HTTP, WebSocket, UPnP | A mod must not go online by itself | The game channel relay_pipe(): PaxNet uses only that |
FileAccess, DirAccess, ConfigFile, user:// | A mod must not touch the player's files | save_data()/load_data(), get_setting()/set_setting() |
ClassDB, Expression, GDScript, str_to_var | They can execute foreign code | Ordinary calls; the PaxNet codec for data |
preload of a foreign path, .. in a path | A mod sees only its own folder | lib.script_of("Name"), library methods |
var x := dict["a"] | The game's strict typing: a type is not inferred from an untyped value | var x: int = dict["a"] |
| Player-facing text in code | The game is multilingual | data/lang/ru.json, en.json and tr_key() |
What changed compared to the original PaxNet
The original PaxNet replaced the game's network service and opened a UDP port itself. That is impossible inside the mod sandbox, so it was reworked into a library.
For the sandbox
- No custom sockets any more. Removed
UdpSocket,WsSocket, UPnP, NAT punch-through and joining byip:port. Traffic goes through the game channelNetwork.relay_pipe(), the same way as in doom_2. The newPipeHostandPipeGuestgive the core its old interface, so sessions, sync checks and world transfer were not rewritten. - The game's network service is not replaced. A library must not silently change online play for everyone who enabled it, and cannot take away the channel the game itself uses.
- All paths are full literals
res://mods/paxnet_core/…, without... FileAccess.COMPRESSION_ZSTDreplaced with the number2(verified against Godot 4.7).
Other
- The shared random generator mixes the stream name with FNV-1a instead of
hash(). The result is identical on any engine version, but the sequences differ from the old PaxNet. - Translation keys renamed to
paxnet_…; all text moved out of code. - The link on top of the reliable channel retransmits less: retries no sooner than 400 ms, a keep-alive every 500 ms, disconnect after 15 s of silence.
- The host says goodbye to everyone when leaving. Guests see "The host closed the game" and do not try to return.
MPL 2.0 in plain words
PaxNet is distributed under the Mozilla Public License 2.0. The full text is in the LICENSE file inside the mod and in the GitHub repository; a short notice sits at the top of every code file.
You may
Use the library in your mod: free or paid, open or closed source.
You may
Copy and distribute PaxNet, including bundled with your mod.
You must
If you modified PaxNet files, distribute the modified files under MPL 2.0 with source code too.
You need not
Open the source of your own files. A mod that only uses the library may have any license.
You may not
Remove license notices from PaxNet files.
This is a summary, not legal advice. If it matters, read the full license text.
Protocol
You do not need to know this to use the library. The core protocol is unchanged (Proto.VERSION = 1). Link packets now travel as datagrams of the game channel instead of UDP. In the channel the host is number 1, guests are 2 and up. To the core, a guest's address looks like pipe#number.
Link packet
0xA7 | conn u32 | flags u8 (bit 0: ack valid) | seq u16 | ack u16 | ack_bits u32 | records… | HMAC-SHA256 (8 bytes)
record: 1 reliable : varint id | u8 type | varint length | body
2 slot : u8 type | varint length | body ("latest value")
3 oneshot : u8 type | varint length | body (no retries: transfer chunks)Acknowledgements ride in every packet: the last received seq plus a bitmap of 32 more. Reliable messages are delivered in order and exactly once. Lost ones are resent: quickly (when newer packets are acknowledged) or on the timer srtt + 4·rttvar. No more than 6 packets per poll, a packet at most 1200 bytes.
Handshake
guest → HELLO(version, nonce_c) padded to 96 bytes
host → CHALLENGE(nonce_c, window, cookie) cookie = HMAC(host secret, address | nonce_c | window)
guest → CONNECT(nonce_c, window, cookie, info) info: name, player key, mods, has world, last step, checksum
host → ACCEPT(conn_id, welcome) | REJECT(code)
frame: 0xA6 | type | body | CRC32; link key = HMAC(cookie | nonce_c, "pax-link")A link is recognised by its signature on any address. So a guest that reopened the channel under a new number continues the same link without a new handshake.
Core messages
| # | Message | Who → whom | Delivery |
|---|---|---|---|
| 10 | LOBBY: the whole lobby | host → all | reliable, in parts |
| 11 | LOBBY_CMD: seat, ready, name, rules, start | guest → host | reliable |
| 12 | CHAT | anyone → host → all | reliable |
| 20 / 21 | ACTION / ACTION_RESULT | guest ↔ host | reliable, redundant; deduplicated by number |
| 30 | CLOSURE: world step | host → all | reliable, strictly ordered |
| 31 / 32 / 33 / 34 | REPORT, RESYNC, PATCH, PATCH_BLOB | checks and healing | slot / reliable |
| 40–44 | WORLD, XFER_*: world transfer | host → guest | chunks, selective resend, CRC32 |
| 50 / 51 | TIME_CMD / TIME | guest → host / host → all | reliable |
| 60 / 61 | PING / PONG | anyone | slot |
| 70 / 71 | NOTICE / BYE | host → guest / anyone | reliable |
| 73 | FRAG: a chunk of a large message | anyone | reliable, ordered |
| 80 / 81 | APP / APP_ACK: mod messages | anyone → host → recipient | numbered, "everything up to N", resend after a drop |
World step (CLOSURE)
| Field | Meaning |
|---|---|
t | step number, grows by 1 without gaps |
n | days elapsed (0 means a step with orders only) |
iv | ms between steps on the host: the guest keeps the same rhythm |
a | orders in sequence: {p, s, r, c, k, g} |
ev, nw, ai | system events, numbered news, neural-network results |
h, p | section checksums after the step; the step carries world changes |
How it was tested
32 of 32
library scripts compile in Godot 4.7 with strict typing, as in the game.
43 of 43
network test checks pass on a virtual relay with 40 ms latency.
What the network test covers: room and code; joining via link and via PAX-…; the roster on everyone; 60 messages in order without duplicates; a private A→B message reaching only B; 300 KB byte for byte; ready and start; a guest channel drop with return under the same number, with everything sent during the drop arriving exactly once and in order; the host leaving. For the world: 10 steps as diffs, healing a corrupted section without resending the world, returning to the same seat after a restart, the ScoreWorld adapter from this page. Plus join errors.
Run it yourself
The tests come as a separate archive for developers. Download it and unpack it so you get a paxnet-tests folder; in the commands below replace /paxnet_core/paxnet-tests with your path to it.
godot --headless --path /paxnet_core/paxnet-tests res://tests/compile.tscn # compile all scripts
godot --headless --path /paxnet_core/paxnet-tests res://tests/net.tscn # network test
python3 /paxnet_core/paxnet-tests/sandbox_scan.py /paxnet_core/paxnet-tests/mods/paxnet_core paxnet_core # sandbox scannerWhat to check in the game itself
godot --headless --path <game folder> -- --check-mods paxnet_core: make sure the real check agrees with the scanner.- That your game version has
Network.relay_pipe(). If not,lib.pipe_detail()and the log ([paxnet] relay_pipe not found) show what was found. - A game in the game world: console F8,
paxnet host Nameon one computer andpaxnet join CODE Nameon another.
Help PaxNet grow
PaxNet is free and open, and it will stay that way. If the library helped your mod, or you simply want it to keep growing, you can support its development. The money pays for time spent on new features, testing on fresh game versions and this documentation.