PaxNet Core
v1.0.0MPL 2.0Godot 4.7id: paxnet_core
ПоддержатьSupport
Библиотека для модов Pax Universe

PaxNet

Сетевая игра для вашего мода: комната по коду, лобби, сообщения, которые доходят ровно один раз, и возврат после обрыва связи.

A library for Pax Universe mods

PaxNet

Online play for your mod: rooms with a join code, a lobby, messages delivered exactly once, and automatic return after a dropped connection.

хост (№1)гости (№2, 3…)сообщениеhost (#1)guests (#2, 3…)message
Коротко за 30 секунд

Что это вообще такое

Если совсем короткоМод PaxNet сам ничего в игре не меняет. Это «моторчик» для других модов: с ним мод может играть по сети с друзьями, не написав ни строчки сетевого кода.

Представьте почту. Один игрок (хост) открывает «почтовое отделение» (комнату) и получает его адрес: короткий код вроде 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

  1. Скачайте PaxNet Core из мастерской

    Откройте лаунчер Pax Universe → Мастерская, найдите «PaxNet Core» и нажмите «Скачать».

  2. Включите его в разделе «Моды»

    Лаунчер → Моды → поставьте галочку у «PaxNet Core» и у мода, который его попросил. Игра применит моды при следующем запуске.

Что видно на экранеЕсли мод включил строку состояния, вверху экрана будет: «Вы хост · код D3EF5YXQ · игроков: 3 · 42 мс». При ошибке появится окно: что случилось и что нажать, например кнопка «Войти снова». Как открыть комнату и войти в неё, расскажет сам мод, который пользуется PaxNet.
Своими руками

Первый сетевой мод: пошагово

Что получитсяМод «Привет, сеть»: чат между игроками через консоль разработчика (F8). Один набирает hello host и получает код, второй вводит hello join КОД. После этого hello say Привет! показывает сообщение у всех. Это меньше 90 строк кода, и у каждого куска есть комментарий.

Весь код на этой странице проверен: он компилируется в Godot 4.7 со строгой типизацией, как в игре, и проходит проверку песочницы.

  1. Создайте папку мода

    В папке mods игры создайте папку hello_net. Имя папки = id мода: латиница, цифры и _. Вот что будет внутри в конце:

    структура папки
    mods/
    └── hello_net/
        ├── mod.json            ← паспорт мода: имя, версия, от чего зависит
        ├── main.gd             ← код мода, первая строка: extends PaxMod
        └── data/
            └── lang/
                ├── ru.json     ← тексты на русском
                └── en.json     ← тексты на английском
  2. Напишите mod.json: паспорт мода

    Главная строка: "depends": ["paxnet_core"]. Она говорит игре: «без PaxNet этот мод не работает, загрузи библиотеку раньше меня».

    mod.json
    {
    	"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. Тогда ваш мод может загрузиться раньше библиотеки и не найти её.
  3. Добавьте тексты в data/lang

    В Pax Universe нельзя писать текст для игрока прямо в коде: всё, что видит игрок, лежит в файлах перевода (правило игры). В коде вы пишете ключ, например tr_key("hello_net_me"), а игра подставляет текст на языке игрока. Ключи начинайте с id мода, чтобы не совпасть с чужими.

    data/lang/ru.json
    {
    	"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 устроен так же, но по-английски. Нужны как минимум эти два языка.

  4. Напишите main.gd: сам мод

    Первая строка всегда extends PaxMod. Прочитайте комментарии: в них сказано, что делает каждый кусок.

    main.gd
    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 срабатывает, когда сообщение пришло к вам.
  5. Проверьте мод

    В папке игры выполните команду. Код выхода 0 означает, что ошибок нет:

    Терминал
    godot --headless --path <папка игры> -- --check-mods hello_net
  6. Включите оба мода и запустите игру на двух компьютерах

    Лаунчер → Моды → галочки у «PaxNet Core» и «Привет, сеть». Перезапуск.

  7. Сыграйте

    Нажмите 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", "Привет"). Вот что происходит дальше, сверху вниз. Знать это не обязательно, но так проще понять ошибки.

ваш модВы вызываете send()

Сообщение получает номер в очереди: 1, 2, 3… По этим номерам получатель потом отсеет повторы и восстановит порядок.

AppChannelУпаковка

Данные переводятся в байты своим кодеком. Если вышло больше 4 КБ, байты сжимаются. Большое сообщение режется на куски.

UdpLinkЗвено с подписью

Куски складываются в пакеты до 1200 байт. Каждый пакет подписан ключом, который знают только этот гость и хост. Подделать пакет нельзя.

PipeGuestВ канал игры

Пакет уходит в канал ретранслятора, адресат: хост, номер 1.

mp.paxuniverse.ruРетранслятор игры

Сервер Pax Universe пересылает пакет хосту по защищённому соединению (wss, порт 443).

хостПроверка и сборка

Хост проверяет подпись, собирает куски, распаковывает. Подтверждает: «получил всё до номера N».

хост → адресатДоставка

Если сообщение было для хоста, у хоста срабатывает сигнал message. Если для другого гостя или для всех, хост пересылает его дальше тем же путём и сам указывает настоящего автора.

А если связь оборвалась посередине?Неподтверждённые сообщения остаются в очереди у отправителя. Когда связь вернётся, они уйдут снова. Получатель увидит знакомые номера и выбросит то, что у него уже есть. Поэтому выходит ровно один раз и по порядку.

Кто кому может написать

ВызовКто получитПоле from_id у получателя
send(1, …) у гостяТолько хостномер гостя
send(3, …) у гостя №2Только гость №3 (через хоста)2
send(0, …) или broadcast(…) у гостяХост и все гости, кроме автораномер автора
send(2, …) у хостаТолько гость №21
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   # библиотека не включена: покажите игроку подсказку
Почему не preload?Песочница игры пускает 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_players5Сколько людей в партии вместе с хостом. Шестой получит ошибку full.
join_in_progresstrueМожно ли входить, когда партия уже идёт. При false новенькие получают started (вернувшиеся по ключу проходят всегда).
time_mode"host"Кто двигает время. "host": только хост. "vote": голосование (паузу может сразу поставить любой, а снять паузу или сменить скорость можно большинством голосов за 15 секунд).
allow_corulertrueМожно ли играть вдвоём за одно место (соправитель).
ready_requiredfalsestart() не сработает, пока все не нажали «готов» (вернёт {ok: false, code: "not_ready"}). Для not_ready в библиотеке нет готового текста paxnet_err_…: сообщение игроку напишите сами.
room_nameимя хостаНазвание комнаты на ретрансляторе.
publicfalseПросьба ретранслятору показать комнату в общем списке. Вход по коду работает всегда.
preset"paxnet"Метка комнаты для ретранслятора. Поставьте id своего мода.
want_code""Попросить конкретный код (продолжение сохранённой партии, комната дня). Если код занят, ретранслятор может дать другой. Смотрите code() после события room.
mods""Строка-«отпечаток» модов. Если не пусто, гость с другой строкой в join(…, {"mods": …}) получит ошибку mods.
authorityfalseДля синхронизации мира: При true мир считает только хост и рассылает изменения. Смотрите свой мир.
external_timefalseПри true шаги мира делаете вы сами вызовом session.record_step(дни), а не таймер скорости. До start() вызов ничего не делает.
push_min_ms0В режиме 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_takenchoose_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] = data

2. Ходы по очереди: хост судит

Самая надёжная схема для пошаговых игр и всего, где можно сжульничать. Гость только просит сделать ход. Хост проверяет правила и рассылает всем новое состояние. Гости верят только хосту.

рецепт
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))
Для продвинутых

Свой мир: сверка и лечение

Нужно ли это вам?Если вашему моду хватает сообщений, то нет: пропустите раздел. Он нужен, когда у всех игроков должен быть одинаковый большой мир: карта, экономика, армии. PaxNet сам следит, чтобы миры не разошлись, и чинит их.

Идея

Мир делится на разделы, то есть словари с именами ("score", "map", "units"). Для каждого раздела считается сумма. Хост рассылает свои суммы. Гость сравнивает их со своими. Если раздел "map" не совпал, гость тихо просит у хоста только его. Хост присылает, гость вставляет, и мир снова одинаковый, игра не останавливалась.

Два режима

Мир считает хост authority: true

Хост меняет мир и вызывает record_step(). Гостям уходят только изменения (разница) вместе с суммами. Гости ничего не пересчитывают сами. Подходит почти всем.

Все считают одинаково authority: false

Каждый шаг у всех одинаковые приказы, число дней и случайности. Каждый пересчитывает мир сам, суммы ловят расхождения (как в Factorio). Нужен строго детерминированный код.

Адаптер: ваш мир для PaxNet

Адаптер представляет собой объект с методами, которые PaxNet вызывает по имени. Наследовать что-то из библиотеки не нужно (это и невозможно: чужой extends песочница не пустит). Нужно, чтобы были все методы. Вот полный рабочий пример: мир с очками и флагом. Он проверен сетевым тестом: мир доставляется, изменения доходят.

ScoreWorld.gd
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

Как им пользоваться

main.gd
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")

Звено узнаётся по подписи на любом адресе. Поэтому гость, переоткрывший канал под новым номером, продолжает то же звено без нового рукопожатия.

Сообщения ядра
№СообщениеКто → комуДоставка
10LOBBY: лобби целикомхост → всенадёжно, частями
11LOBBY_CMD: место, готовность, имя, правила, стартгость → хостнадёжно
12CHATлюбой → хост → всенадёжно
20 / 21ACTION / ACTION_RESULTгость ↔ хостнадёжно, избыточно; отсев по номеру
30CLOSURE: шаг мирахост → всенадёжно, строго по порядку
31 / 32 / 33 / 34REPORT, RESYNC, PATCH, PATCH_BLOBсверка и лечениеячейка / надёжно
40–44WORLD, XFER_*: передача мирахост → гостькуски, выборочная досылка, CRC32
50 / 51TIME_CMD / TIMEгость → хост / хост → всенадёжно
60 / 61PING / PONGлюбойячейка
70 / 71NOTICE / BYEхост → гость / любойнадёжно
73FRAG: кусок большого сообщениялюбойнадёжно, по порядку
80 / 81APP / 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 # сканер песочницы

Что проверить в самой игре

  1. godot --headless --path <папка игры> -- --check-mods paxnet_core: убедиться, что настоящая проверка согласна со сканером.
  2. Что в вашей версии игры есть Network.relay_pipe(). Если нет, lib.pipe_detail() и журнал ([paxnet] relay_pipe not found) покажут, что нашлось.
  3. Партию в мире игры: консоль F8, paxnet host Имя на одном компьютере и paxnet join КОД Имя на другом.
Поддержать проект

Помочь развитию PaxNet

PaxNet бесплатный и открытый, и таким останется. Если библиотека пригодилась вашему моду или вы просто хотите, чтобы она росла, можно поддержать разработку. Эти деньги покрывают время на новые возможности, проверку на свежих версиях игры и эту документацию.

Это пожертвование, а не покупкаВзамен ничего не продаётся и ничего не открывается: все возможности PaxNet доступны всем одинаково, с поддержкой и без неё.
Способ
Сумма

Оплата проходит на странице ЮKassa или Heleket. Данные карты и кошелька этот сайт не видит и не хранит.

The 30-second version

What is this, anyway

In a nutshellThe PaxNet mod changes nothing in the game by itself. It is an engine for other mods: with it, a mod can be played online with friends without writing a single line of networking code.

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.

Glossary

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.

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.

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…

If you just play

How to install it when a mod asks for PaxNet

  1. Download PaxNet Core from the Workshop

    Open the Pax Universe launcher → Workshop, find "PaxNet Core" and click "Download".

  2. 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.

What you see on screenIf the mod enabled the status line, the top of the screen shows: "You are the host · code D3EF5YXQ · players: 3 · 42 ms". On an error a window appears saying what happened and what to click, for example a "Rejoin" button. The mod that uses PaxNet will explain how to open a room and join.
Hands-on

Your first network mod, step by step

What you will buildThe "Hello Net" mod: chat between players through the developer console (F8). One player types 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.

  1. Create the mod folder

    In the game's mods folder, create a folder hello_net. Folder name = mod id: Latin letters, digits and _. Here is what it will contain at the end:

    folder layout
    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 texts
  2. Write 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".

    mod.json
    {
    	"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 mistakeForgetting depends. Then your mod may load before the library and fail to find it.
  3. 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.

    data/lang/en.json
    {
    	"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.json file has the same keys in Russian. At least these two languages are required.

    data/lang/ru.json
    {
    	"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"
    }
  4. Write main.gd, the mod itself

    The first line is always extends PaxMod. Read the comments: they say what each piece does.

    main.gd
    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. The message signal fires when a message reaches you.
  5. Check the mod

    Run this command in the game folder. Exit code 0 means no errors:

    Terminal
    godot --headless --path <game folder> -- --check-mods hello_net
  6. Enable both mods and run the game on two computers

    Launcher → Mods → tick "PaxNet Core" and "Hello Net". Restart.

  7. Play

    Press F8 to open the console.

    WhoWhat to typeWhat happens
    Hosthello host"Opening the room…". The room is ready in 1–2 seconds.
    Hosthello codeAn invite link, for example https://mp.paxuniverse.ru/j/D3EF5YXQ. Send it to a friend.
    Friendhello join D3EF5YXQThe friend enters the room; the host sees "Player joined the game". Pasting the whole link works too.
    Anyonehello say Hi!Everyone else sees "Player: Hi!".
    Anyonehello 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.
Under the hood

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.

your modYou call send()

The message gets a sequence number: 1, 2, 3… The receiver uses these numbers to drop duplicates and restore order.

AppChannelPacking

The data is turned into bytes by PaxNet's own codec. Anything over 4 KB is compressed. A large message is split into chunks.

UdpLinkSigned link

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.

PipeGuestInto the game channel

The packet goes into the relay channel, addressed to the host, number 1.

mp.paxuniverse.ruThe game relay

The Pax Universe server forwards the packet to the host over a secure connection (wss, port 443).

hostVerify and reassemble

The host checks the signature, reassembles the chunks, decompresses. It acknowledges: "received everything up to number N".

host → recipientDelivery

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.

What if the connection drops halfway?Unacknowledged messages stay in the sender's queue. When the connection returns they are sent again. The receiver sees familiar numbers and discards what it already has. That is how you get exactly once, in order.

Who can message whom

CallWho receives itfrom_id at the receiver
send(1, …) on a guestOnly the hostthe guest's number
send(3, …) on guest #2Only guest #3 (via the host)2
send(0, …) or broadcast(…) on a guestThe host and all guests except the authorthe author's number
send(2, …) on the hostOnly guest #21
broadcast(…) on the hostAll guests1
send(own_number, …)Nobody: you never receive your own messagesnone
Session lifecycle

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.

connecting

Opening or entering the room. Usually 1–2 seconds.

lobby

Everyone is in the room; the game has not started. Messages already flow.

syncing

A guest is receiving the world from the host. The host's game keeps running.

playing

The game is on.

reconnecting

The connection dropped; PaxNet brings the player back by itself. Nothing to do.

failed

The connection did not come back. Show the player a "Rejoin" button (rejoin()).

closed

The 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().

Note for the hostThe host never has 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.
Reference

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:

recipe
## 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 hint
Why not preload?The game sandbox allows load() 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.
MethodWhat it doesExample
new_session(parent = null) → NodeCreates 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) → ObjectA raw relay channel for your own protocol (as in doom_2). See recipe 6.lib.open_pipe({"op": "join", "code": c})
overlay(session, parent) → CanvasLayerA 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) → PackedByteArrayValue to bytes. Custom codec: objects are never encoded, depth and lengths are limited.var b: PackedByteArray = lib.encode({"hp": 5})
decode(bytes) → DictionaryBytes 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 = "") → RefCountedA 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) → intA 32-bit fingerprint of a value, independent of dictionary key order. For sync checks.lib.state_hash(world)
parse_code(text) → StringThe 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) → StringInvite link for a code.lib.invite_link("D3EF5YXQ")
shared_code(seed, stream, prefix = "") → StringA 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() → intThe current UTC day number: the same for every player in the world.lib.utc_day()
player_key() → StringThe persistent player key on this computer (shared by all mods).lib.player_key()
script_of(name) → ScriptA 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() → NodeA 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() → NodeSame, but does not create it: null if it does not exist yet.
pipe_detail() → StringWhat 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().

Reference

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

MethodWhoWhat it doesExample
host(name, adapter = null, cfg = {}) → StringhostOpen 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 = {}) → StringguestJoin 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() → DictionaryhostStart 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()bothLeave. 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() → StringguestJoin again after a connection error (status failed): same key, same seat.net.rejoin()
request_full_world()guestAsk for the entire world if it diverged and did not heal (desync error).

Messages

MethodWhoWhat it doesExample
send(to, type, data = null) → boolbothSend 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) → boolbothSame as send(0, …): everyone except yourself.net.broadcast("my_mod.pos", Vector2(3, 4))
action(kind, args = null) → VariantbothAn 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

MethodWhoWhat it doesExample
set_ready(on)bothThe "ready" mark.net.set_ready(true)
choose_seat(seat, as_coruler = false)bothTake 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)bothA 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)hostKick a player: they see "The host removed you from the game".net.kick(3)
lobby_cmd(cmd) → VariantbothAny 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

MethodWhoWhat it doesExample
time_cmd(kind, value = null)bothkind: "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() → Dictionaryboth{paused, speed, by, by_name, mode, votes, v}: whether paused, who paused, whether a vote is on.

Querying state

MethodWhoWhat it doesExample
is_host() / is_guest() → boolbothWho am I in this game.
active() → boolbothIn a game and connected: status lobby, syncing or playing.
my_id() → intbothMy number: 1 on the host, 2… on a guest. 0 if the guest has not joined yet.
code() → String / invite_link() → StringbothRoom code and invite link.
players() → ArraybothAll players: [{id, name, seat, role, ready, ai, ping, host, away}]. away: connection lost, seat is held.
player_name(id) → StringbothPlayer name by number ("?" if not found).
rtt_ms() → floatguestPing to the host in milliseconds.
state() → DictionarybothEverything at once: role, status, code, players, time, notice, rtt, id. Handy for your own lobby window.

Signals

SignalWhen 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)

kindOnMeaningdata
roomhostRoom opened, code ready{code}
reopeninghostThe relay lost the room; reopening it with the same code{try}
relay_errorhostThe relay did not give a room{code, why}
peer_joinedhostA player joined or returned{id, name, reconnect, mode}
peer_lefthostA player left or vanished (their seat is held){id, why}; why: bye, timeout, kicked
lobbybothThe lobby was updatedthe whole lobby state
startedhostThe game started{}
world_sent / world_deliveredhostWorld sent to a guest / the guest assembled it{id, …}
stephostA world step was made{t, n}
connectedguestConnected to the host (also after a return){id, mode, reconnect}
lostguestConnection lost, starting to come back{}
world_installedguestThe host's world was received and installed{tick}
patched / mismatchguestWorld changes arrived / a divergence was found (will be healed){names, tick}
action_rejectedguestThe host rejected an order{c, ok, code}
closed / failedguestThe host closed the game / the connection did not return{why} / {code}

Readable fields

FieldContents
role"host", "guest" or ""
statusSee status
last_noticeThe last error: {what, do, code, args}. what and do are translation keys: tr_key(n["what"])
player_keyThe player key. Issued by the library, shared by all mods
defaultsDefault room settings (see below)
session, transport, adapterInternals (HostSession/GuestSession, PipeHost/PipeGuest, your adapter), for advanced use
Reference

Room settings

The third argument of host(name, adapter, cfg) is a dictionary. Everything is optional: any key you omit takes its default.

example
net.host("Anna", null, {"max_players": 4, "time_mode": "vote", "join_in_progress": false})
KeyDefaultMeaning
max_players5How many people in the game including the host. The sixth gets the full error.
join_in_progresstrueWhether 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_corulertrueWhether two people can play one seat together (co-ruler).
ready_requiredfalsestart() 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_namehost's nameRoom name on the relay.
publicfalseAsk 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.
authorityfalseFor world sync: with true only the host computes the world and broadcasts the changes. See your own world.
external_timefalseWith 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_ms0In authority mode: minimum milliseconds between world-change broadcasts.
Reference

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.

TypeAllowed?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 nullBreak them into numbers or arrays
Object, Node, Resource, Callable❌ arrives as nullSend an id or a name, not the object itself

Limits

WhatLimitWhat happens above it
One message after compression≈ 4.5 MB (4096 chunks of ~1.1 KB)Not sent. Split it yourself
CompressionTurns on automatically for messages from 4 KBnothing
Message typeAny string; the convention is "mod_id.what"nothing
An action() orderup 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 rowLine dropped, the author gets rate
send() rateNo hard limit10–20 times per second per player is fine; 60 times per second with large messages is already a lot
Validate what arrivesData comes from another person, who may have a different mod version or a bug. Always check the type: 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.
Reference

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).

show an error to the player
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)
Where errors arriveConnection and join errors (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.
CodeWhen it happensWhat the player seesWhat to do
versionThe 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.
modsThe 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.
fullThe 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.
codeThe 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.
bannedReserved: the library does not issue this code at the moment.The host has barred you.Message the host.
startedThe 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_takenchoose_seat on a seat that already has a player.Another player already controls this country.Pick another country or become a co-ruler.
timeoutNo 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.
kickedThe host called kick(id).The host removed you from the game.Message the host.
host_leftThe 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.
illegalAn 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_onlyA 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.
rateToo 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_packetReserved: corrupted packets are dropped silently.Corrupted data arrived.If it happens again, rejoin.
desyncThe 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_routeThe 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.
relayNo 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.
Recipes

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.

recipe
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] = data

2. 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.

recipe
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 board
Why this wayIf everyone changes the shared state themselves and broadcasts it, two players may move at the same time and end up with different boards. When one host decides, there is one truth.

3. 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.

recipe
## 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.

recipe
## 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.

recipe
## 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.

recipe
## 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))
Advanced

Your own world: checks and healing

Do you need this?If messages are enough for your mod, no: skip this section. It is for when all players must have the same large world: a map, an economy, armies. PaxNet watches that worlds do not diverge and repairs them.

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.

ScoreWorld.gd
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) & 0xFFFFFFFF

How to use it

main.gd
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 only

Adapter methods: what each must do

MethodWhat to return or do
section_names() → ArraySection names
section_period(name) → intCheck the section every N steps (heavy ones less often). Usually 1
hashes(tick, all) → DictionaryChecksum 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) → DictionaryFull 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) → PackedByteArrayThe whole world as bytes (for a joining guest)
install_world(bytes) → boolInstall the world from bytes. false if it failed (desync error)
view_of(seat) → VariantThe "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() → intCurrent 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) → StringWho may issue the order: "own" (the seat owner), "free" (anyone), "host" (host only)
execute_action(actor, action, ctx) → DictionaryExecute 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) → DictionaryA deal between players (trade, alliance, war) was executed
auto_reply(kind, from, to, terms) → boolThe answer of a seat run by the AI
countries() → ArrayLobby seats: [{id, name}]
on_news, on_ai_result, on_action_result, on_chat, on_notice, on_roster_changed, on_progress, on_time_stateDisplay only; they do not affect the world. May be empty
Common mistakeComputing a dictionary checksum with the built-in 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.
Advanced

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.

CallWhat 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 leaveThe same, for manual testing.
CarefulThis part relies on game internals (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.

Help

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().

Game rules

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 allowedWhyUse instead
Sockets, HTTP, WebSocket, UPnPA mod must not go online by itselfThe game channel relay_pipe(): PaxNet uses only that
FileAccess, DirAccess, ConfigFile, user://A mod must not touch the player's filessave_data()/load_data(), get_setting()/set_setting()
ClassDB, Expression, GDScript, str_to_varThey can execute foreign codeOrdinary calls; the PaxNet codec for data
preload of a foreign path, .. in a pathA mod sees only its own folderlib.script_of("Name"), library methods
var x := dict["a"]The game's strict typing: a type is not inferred from an untyped valuevar x: int = dict["a"]
Player-facing text in codeThe game is multilingualdata/lang/ru.json, en.json and tr_key()
History

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 by ip:port. Traffic goes through the game channel Network.relay_pipe(), the same way as in doom_2. The new PipeHost and PipeGuest give 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_ZSTD replaced with the number 2 (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.
License

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.

For the curious

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
format
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
exchange
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
#MessageWho → whomDelivery
10LOBBY: the whole lobbyhost → allreliable, in parts
11LOBBY_CMD: seat, ready, name, rules, startguest → hostreliable
12CHATanyone → host → allreliable
20 / 21ACTION / ACTION_RESULTguest ↔ hostreliable, redundant; deduplicated by number
30CLOSURE: world stephost → allreliable, strictly ordered
31 / 32 / 33 / 34REPORT, RESYNC, PATCH, PATCH_BLOBchecks and healingslot / reliable
40–44WORLD, XFER_*: world transferhost → guestchunks, selective resend, CRC32
50 / 51TIME_CMD / TIMEguest → host / host → allreliable
60 / 61PING / PONGanyoneslot
70 / 71NOTICE / BYEhost → guest / anyonereliable
73FRAG: a chunk of a large messageanyonereliable, ordered
80 / 81APP / APP_ACK: mod messagesanyone → host → recipientnumbered, "everything up to N", resend after a drop
World step (CLOSURE)
FieldMeaning
tstep number, grows by 1 without gaps
ndays elapsed (0 means a step with orders only)
ivms between steps on the host: the guest keeps the same rhythm
aorders in sequence: {p, s, r, c, k, g}
ev, nw, aisystem events, numbered news, neural-network results
h, psection checksums after the step; the step carries world changes
Quality

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.

Terminal
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 scanner

What to check in the game itself

  1. godot --headless --path <game folder> -- --check-mods paxnet_core: make sure the real check agrees with the scanner.
  2. 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.
  3. A game in the game world: console F8, paxnet host Name on one computer and paxnet join CODE Name on another.
Support the project

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.

A donation, not a purchaseNothing is sold or unlocked in return: every PaxNet feature is the same for everyone, whether they donate or not.
Method
Amount

Payment happens on the YooKassa or Heleket page. This site never sees or stores your card or wallet details.