Мультиплеер

Net — серверно-авторитативный мультиплеер для 3D-игр поверх UDP. Один бандл выполняет каждую роль — офлайн, хост, клиент и выделенный сервер. Сервер симулирует. Каждый клиент отправляет свой ввод каждый тик, предсказывает своего игрока и показывает всех остальных с интерполяцией. Replicated помечает узел как сетевую сущность, NetEntity — запись этой сущности на каждой машине, а NetPlayer — один подключённый игрок. Два других сетевых API закрывают другие задачи: WebSocket — обычный сокет к веб-серверу (network.md), а серверные функции — вызовы бэкенда приложения (server-functions.md).

Обзор

TypeScript
// Один бандл, каждая роль: `lecodes serve` = выделенный сервер, `lecodes desktop run --connect
// 127.0.0.1:40000` = клиент, просто `lecodes desktop run` = офлайн (та же игра, один игрок).
const scene = new Scene({ skybox: "#8fb2d9" })
scene.add(Light.sun({ direction: [-0.4, -0.9, -0.35] }))
scene.camera.position = [0, 12, 14]
scene.camera.lookAt([0, 0, 0])

// Общий словарь: ввод этого тика и реплицируемый тип сущности.
const Move = Net.input({ x: 0, z: 0 })
const PlayerKind = Net.kind("player", { pos: [0, 0, 0] }, {
  transform: "pos",                          // state.pos зеркалит node.position
  client: (rep, scene) => {                  // клиент рождает свой прокси, когда сервер спавнит сущность
    const node = makePlayer(rep.isMine)
    scene?.add(node)
    return node
  },
})

function makePlayer(mine: boolean): Node {
  return Mesh.box({ size: 1, material: Material.lit({ color: mine ? "#e0743c" : "#4f7cff" }) })
    .aspect(Walk)
}

// Ввод: раз в тик, на каждой машине с локальным игроком.
class Controls extends System<"controls"> {
  updateFixed() {
    if (Net.role === "server") return        // у выделенного сервера нет своего игрока
    const x = (Input.key("KeyD") ? 1 : 0) - (Input.key("KeyA") ? 1 : 0)
    const z = (Input.key("KeyS") ? 1 : 0) - (Input.key("KeyW") ? 1 : 0)
    Net.setInput(Move, { x, z })
  }
}
scene.system(Controls)

// Симуляция: один и тот же код на сервере (каждый игрок) и на клиенте (его собственный игрок).
class Walk extends Aspect<"walk", Node> {
  static after = [Controls]
  updateFixed(dt: number) {
    const rep = this.node.get(Replicated)
    if (!rep?.simulated) return              // чужой игрок: SDK двигает прокси сам
    const move = rep.input(Move)
    this.node.x += move.x * 4 * dt
    this.node.z += move.z * 4 * dt
  }
}

// Сервер решает, что существует: узел с Replicated существует на каждом клиенте.
const spawn = (p: NetPlayer) => scene.add(makePlayer(p.isMe).aspect(Replicated, { kind: PlayerKind, owner: p }))
Net.on("join", (p) => { if (Net.isServer) spawn(p) })

scene.open()
const launch = Net.launch
if (launch?.role === "server") Net.listen({ port: launch.port, maxClients: launch.maxClients })
else if (launch?.role === "client") Net.connect({ address: launch.address }, { name: "Guest" })
else spawn(Net.me)                           // офлайн
TypeScript
lecodes serve                                   # dedicated server, windowless (port 40000, 16 slots)
lecodes serve --connect 127.0.0.1:40000         # a headless client: a bot, a scripted test player
lecodes desktop run --connect 127.0.0.1:40000   # a client window
lecodes desktop run                             # offline

Клиент двигает свой куб сразу: он предсказывает тем же кодом Walk, который выполняет сервер, так что оба сходятся тик в тик. Кубы других игроков плавно идут примерно на 100 мс позади.

Роли и сессии

Net.role как isServer isClient Net.me
"offline" по умолчанию true true локальный игрок, id 1
"host" Net.listen() в игре, которая держит своего игрока true true игрок хоста, id 1
"server" Net.listen() на exe, запущенном с --server true false null
"client" Net.connect() false true id, назначенный сервером

Правила ограждай через Net.isServer, представление — через Net.isClient. Офлайн удовлетворяет обоим, так что игра, написанная под мультиплеер, без изменений работает и в одиночном режиме.

TypeScript
Net.launch: NetLaunch | null     // как был запущен exe; null = без флага мультиплеера
Net.listen(opts?): boolean       // слушать; false + событие "error", когда не получилось
Net.connect({ address }, opts?): Promise<void>   // резолвится, когда сервер нас принял
Net.disconnect(): void
Net.role: NetRole                // "offline" | "host" | "client" | "server"
Net.status: NetStatus            // "idle" | "connecting" | "online" | "error"
Net.isServer / Net.isClient / Net.supported: boolean
  • Net.launch сообщает флаги командной строки хоста. Сам он никогда ничего не подключает.
    • --server [--port N] [--max-clients N] даёт { role: "server", address: "", port, maxClients }.
    • --connect ip:port даёт { role: "client", address, port: 40000, maxClients: 16 }.
    • lecodes serve и lecodes desktop run --server | --connect передают эти флаги.
  • Опции Net.listen:
    • port: по умолчанию 40000.
    • maxClients: по умолчанию 16, не больше 64.
    • name: имя игрока этой машины.
    • dedicated: нет ли локального игрока. По умолчанию true, когда exe запущен с --server, иначе false. Передай dedicated: false, чтобы окно с --server стало хостом.
    • accept: см. рукопожатие ниже.
  • Из офлайна Net.listen делает эту машину хостом, а офлайновый Net.me остаётся игроком хоста.
  • Net.connect принимает IP-адрес и порт, "192.168.1.10:40000". Имя хоста отклоняется сразу: bad address 'localhost:40000'. Опции: name (по умолчанию "Player") и hello (ниже).
  • Net.disconnect() на клиенте уходит в офлайн и вызывает disconnect ("disconnected"). На хосте или сервере отключает каждого игрока (по leave на каждого, причина "server closed") и уходит в офлайн.
  • Остановка процесса сервера (Ctrl+C на lecodes serve) завершает сессию: каждый клиент получает disconnect с "disconnected by server".
TypeScript
Net.on("connect", () => …)             // клиент: принят · хост/сервер: слушает (срабатывает внутри listen())
Net.on("join", (player) => …)           // кто-то присоединился (на клиенте: другой игрок)
Net.on("leave", (player, reason) => …)  // например "disconnected", "timed out", "kicked", "server closed"
Net.on("disconnect", (reason) => …)     // клиент: сессия завершилась ("disconnected by server", …)
Net.on("error", (message) => …)         // неудавшийся connect или listen
Net.on("reject", (reason) => …)         // см. примечание ниже
Net.off(event, cb)                      // on / off возвращают Net

Когда connect или listen не удаётся:

причина что получаешь
по адресу никто не слушает примерно через 10 с connect() отклоняется и срабатывает error: "connection request timed out"
сервер полон сразу: connect() отклоняется и срабатывает error: "connection denied"
имя хоста, неправильный адрес сразу: connect() отклоняется: "bad address '…'"
порт занят listen() возвращает false и срабатывает error: "could not listen on port N"
у хоста нет транспорта connect() отклоняется, или listen() возвращает false и срабатывает error: "multiplayer is not supported on this host"

После попытки с таймаутом, отказом или незанимаемым портом status равен "error". В каждом случае роль возвращается к "offline".

Рукопожатие — hello и accept

TypeScript
// сервер
Net.listen({
  port: 40000,
  accept: (hello, who) => (hello as { build?: number })?.build === 7 ? null : `${who.name}: update the game`,
})
// клиент
Net.connect({ address: "192.168.1.10:40000" }, { name: "Den", hello: { build: 7 } })
  .catch((e) => console.log("could not join:", e.message))

hello клиента — любое JSON-значение, например номер сборки или токен. Серверный accept(hello, { name }) возвращает строку-причину, чтобы отказать клиенту, или ничего, чтобы впустить. Принятый игрок хранит значение как player.hello, которое существует только на сервере. SDK также сам отказывает клиенту в двух случаях: другой протокол SDK или объявления Net.kind / Net.input, отличные от серверных (на практике — другой бандл).

Not implemented

причина отказа никогда не доходит до клиента. Сервер ставит причину в очередь, затем разрывает соединение до того, как сообщение из очереди отправлено. Так что connect() клиента отклоняется с "disconnected by server", а reject никогда не срабатывает. player.kick(reason) работает так же:

  • выгнанный клиент получает disconnect с "disconnected by server";
  • событие leave сервера и остальные клиенты видят причину "kicked";
  • твой текст reason не доходит ни до кого.

Игроки

TypeScript
Net.me: NetPlayer                      // игрок этой машины; null на выделенном сервере
Net.players: readonly NetPlayer[]      // все в сессии, включая Net.me
Net.player(id): NetPlayer | undefined

player.id: number                      // уникален в сессии, назначается сервером
player.name: string                    // то, что передали connect / listen — не уникально
player.isMe: boolean
player.connectedAt: number             // Date.now() мс, когда эта машина узнала об игроке
player.hello: unknown                  // только сервер: что нёс connect() игрока
player.data: any                       // локальная сумка для игровых фактов — НЕ реплицируется
player.rtt / player.loss: number       // мс / проценты
player.send(msg, data)                 // сервер → этот игрок (см. Сообщения)
player.kick(reason?)                   // сервер: отключить этого игрока (см. примечание выше)
player.on("leave", (player, reason) => …) / player.off("leave", cb)
Net.rtt: number                        // клиент: наш round trip до сервера в мс; 0 в остальных случаях
  • На хосте собственный игрок хоста — id 1. На выделенном сервере клиенты считаются с 1.
  • rtt и loss на сервере измеряют канал до этого игрока. На клиенте каждый игрок сообщает канал этой машины до сервера. Собственный игрок хоста сообщает 0.
Note

на клиенте читай Net.me и Net.players только после того, как connect() зарезолвился (или в событии connect). Чтение в офлайне или во время подключения создаёт локального игрока-заглушку, и сейчас он остаётся в Net.players после приёма как устаревшая запись. То же относится к сущностям, заспавненным в офлайне: они остаются после connect(), и их id сталкиваются с сущностями сервера (те прокси никогда не создаются). Уничтожь их до того, как клиент присоединится.

Сообщения

TypeScript
const Chat = Net.message<{ text: string }>("chat")          // надёжно, по порядку
const Ping = Net.message<{ at: number }>("ping", { unreliable: true })

Net.send(msg, data)                          // → на сервер, с машины, у которой есть игрок
Net.broadcast(msg, data, { except? })        // сервер → каждому игроку (включая собственного игрока хоста)
player.send(msg, data)                       // сервер → одному игроку
Net.on(msg, (data, from) => …)               // from = игрок-отправитель на сервере; null = отправлено сервером
Net.off(msg, cb)
TypeScript
Net.on(Chat, (msg, from) => {
  if (from) Net.broadcast(Chat, { text: `${from.name}: ${msg.text}` })   // сервер: ретранслировать всем
  else console.log(msg.text)                                            // сообщение от сервера
})
Net.send(Chat, { text: "gg" })

Эта ретрансляция без изменений работает в каждой роли. На хосте или в офлайне сообщение локального игрока обрабатывается внутри процесса с from = Net.me, а broadcast возвращается с from = null.

  • Полезная нагрузка — JSON; параметр типа существует только в TypeScript. Имя — это id на проводе, и имена, начинающиеся с $, зарезервированы (бросает исключение).
  • Локальная доставка (офлайн или собственный игрок хоста) выполняется асинхронно, после текущего кода, никогда внутри send.
  • Вызов не с той стороны бросает исключение. Net.send не разрешён на выделенном сервере, который вместо этого адресует игроков. Net.broadcast и player.send не разрешены на клиенте.
  • Сообщение — один JSON-текст:
    • ненадёжное: не более 1023 байт UTF-8;
    • надёжное: до 64 КБ;
    • сообщение больше отбрасывается молча — ни ошибки, ни события.

Ввод и тик

TypeScript
const Move = Net.input({ x: 0, z: 0, jump: false })   // ОДИН на игру: значения по умолчанию задают раскладку
Net.setInput(Move, { x, z })        // раз в тик, из updateFixed, на машине с локальным игроком
rep.input(Move)                     // ввод владельца для симулируемого тика
rep.hasInput                        // false до первого ввода владельца (input() до тех пор даёт значения по умолчанию)
Net.tick: number                    // фиксированных шагов с начала сессии; 0 в офлайне
Net.viewTick: number                // клиент: серверный тик, на котором показаны удалённые сущности
  • Поля: number становится f32, boolean — одним битом, а number[] (длиной 1–64) — столькими f32. Длина фиксирована. Строки, объекты и вложенность бросают исключение.
  • Числа передаются как f32: целые точны до 16 777 216.
  • Net.setInput заменяет весь ввод. Пропущенное поле берёт значение по умолчанию, а не предыдущее.
  • Запускай симуляцию в updateFixed(dt). Он срабатывает раз на шаг физики, с dt ровно 1/60 с на каждой машине (аспекты, Time).
    • Сервер даёт сущности каждого игрока тот ввод, который этот клиент задал для того же тика. Опоздавший ввод повторяет предыдущий (Net.stats.missed).
    • rep.input() скрывает, где он выполняется: на сервере читает ввод владельца; на владельце, хосте или в офлайне читает локальный ввод.
  • Net.tick считает фиксированные шаги (60 в секунду), пока идёт сессия. Клиент бежит на 2–8 тиков впереди сервера, чтобы его ввод приходил вовремя.
  • Net.viewTick на клиенте дробный и примерно на 100 мс позади новейшего снапшота. Это тик, который называют серверу, говоря «как я это видел». В остальных случаях он равен Net.tick.

Реплицируемые сущности

TypeScript
const Kind = Net.kind(name, defaults, {
  transform?: "pos" | "pos+rot" | "none",       // по умолчанию "none"
  client?: (rep: NetEntity, scene: Scene | null) => Node | void,
  discrete?: string[],                           // числовые поля, которые никогда не интерполируются
  correction?: Partial<NetCorrection>,           // см. Предсказание
})
node.aspect(Replicated, { kind, owner?: NetPlayer })   // сервер: заспавнить на каждом клиенте
node.destroy()                                          // сервер: удалить везде
Net.entities: readonly NetEntity[]
Net.entity(netId): NetEntity | undefined

Объект defaults — это раскладка снапшота, с теми же правилами полей, что у Net.input. Сервер пишет state, и всё состояние уходит в каждом снапшоте, 20 раз в секунду.

TypeScript
node.replicated / node.get(Replicated)     // аспект, одни и те же члены с обеих сторон:
rep.state                  // сервер: пиши его · клиент, чужой: интерполирован · клиент, свой: новейшее авторитативное
rep.netId / rep.isMine / rep.simulated / rep.ownerPlayer / rep.correction / rep.input(inp) / rep.hasInput
rep.entity: NetEntity      // запись: netId, kind, ownerId (0 = собственная сущность сервера), owner, state, node
rep.entity.on("state", (state) => …) / .off("state", cb)   // клиент: состояние обновилось
  • transform говорит, какие поля зеркалят узел. С "pos" state.pos ([x, y, z]) читается из node.position на сервере и пишется в узел прокси на клиентах. "pos+rot" добавляет state.rot, кватернион [x, y, z, w]. Объявить любой из них без такого поля — исключение.
  • client(rep, scene) выполняется на клиенте, когда сервер спавнит сущность этого типа.
    • Верни узел и добавь его в сцену сам: scene — открытая Scene или null.
    • SDK прикрепляет Replicated к возвращённому узлу и уничтожает его при деспавне.
    • Фабрику можно назначить позже (Kind.client = …), из собственного модуля клиента.
    • Без фабрики сущность существует в Net.entities без узла.
  • Владение: owner — игрок, чей ввод управляет сущностью. simulated равен true на сервере и на машине владельца. Когда игрок уходит, сервер уничтожает каждую сущность, которой тот владел. Сущность без владельца (ownerId 0) принадлежит серверу.
  • Присоединившийся игрок получает каждую живую сущность. Когда сессия клиента завершается, он уничтожает все прокси, которые создал. Собственные узлы сервера остаются.
  • Удалённые сущности интерполируются. Числа смешиваются между двумя снапшотами; булевы и поля discrete берут значение из нового снапшота целиком (используй discrete для индекса или счётчика). Событие state клиента срабатывает с каждым снапшотом для своих сущностей и каждый кадр для интерполируемых.
  • Спавнит только сервер. На клиенте прикрепление Replicated своими руками лишь пишет предупреждение в лог. На прокси поля опций аспекта kind / owner не заданы, так что используй rep.entity.kind и rep.ownerPlayer.

Предсказание и коррекция

Машина владельца выполняет тот же updateFixed с тем же вводом, что и сервер, так что её собственная сущность движется без round trip. Каждый снапшот сравнивается с предсказанием, записанным для этого тика. Когда они расходятся, узел сдвигается на разницу на следующем фиксированном шаге (без перемотки). rep.correction (Vec3) затем держит этот скачок как визуальное смещение, которое затухает до нуля. Рисуй со смещением и никогда не отдавай его физике:

TypeScript
// узел — симулируемое тело; видимый меш — дочерний узел, несущий коррекцию
class Smooth extends Aspect<"smooth", Node> {
  body!: Node
  update() {
    const c = this.node.get(Replicated)?.correction
    if (c) this.body.position = [c.x, c.y, c.z]
  }
}

NetCorrection хранит настройки типа, в мировых единицах и секундах:

  • deadbandY (по умолчанию 0): вертикальное расхождение меньше этого игнорируется. Используй для тела, которое на двух машинах чуть по-разному прилипает к полу.
  • snap (по умолчанию Infinity): расхождение такого размера или больше телепортирует, и correction остаётся нулём.
  • tau (по умолчанию 0.1 с): как быстро затухает смещение; 0 — без сглаживания.

Расхождение меньше 0.005 единиц игнорируется. Коррекция работает только по позиции, так что типу нужен transform: "pos" или "pos+rot".

Диагностика и ограничения

TypeScript
Net.stats      // живые счётчики: snapshots, ahead, lead, nudges, corrections / maxCorrection / sumCorrection, missed, bytesOut
Net.simulate({ latency?: ms, jitter?: ms, loss?: percent })   // только для разработки: ухудшить то, что отправляет ЭТА сторона

Net.simulate влияет только на исходящий трафик этой машины, так что для round trip вызывай его на обоих концах.

тик 60 в секунду (Time.fixedDt)
снапшоты 20 в секунду, не более 1000 байт каждый; сущность стоит 5 байт + 4 на число + её булевы, упакованные по 8 в байт. Сущности, которые не помещаются, ротируются по последующим снапшотам (одно предупреждение).
удалённый вид примерно на 100 мс позади новейшего снапшота
типы не более 255; поля-массивы не более 64 чисел
сообщения ненадёжные ≤ 1023 байт JSON, надёжные ≤ 64 КБ; большие отбрасываются молча
игроки maxClients 1–64
таймаут примерно 10 с тишины завершают соединение

Net.connect создаёт неаутентифицированное LAN / dev-соединение. Подключиться может любой, кто достаёт до порта, так что accept — единственный барьер.

Где работает

Net.supported равен true там, где транспорт скомпилирован:

  • десктопные хосты: окно (lecodes desktop run) и безоконные lecodes serve / render --sim;
  • вариант full Android и iOS SDK.

Он равен false на вариантах 3d, 2d и core, в веб-плеере, Web Lite и headless-хосте на Bun (lecodes render). Без транспорта Net.listen возвращает false, а Net.connect отклоняется (оба сообщают "multiplayer is not supported on this host"). Офлайн-игра всё равно работает: сообщения, Replicated, ввод.

Note

нативная оболочка приложения выбирает наименьший вариант SDK, покрывающий движки, которые использует её бандл, так что чисто 3D-игра получает 3d, где нет транспорта. Принудительно задай "variant": "full" под ios / android в app.json для приложения, которому нужен Net.

Подводные камни

TypeScript
Net.send(Chat, data)                    // ✗ на выделенном сервере: бросает исключение
Net.broadcast(Chat, data)               // ✓ сервер адресует игроков

node.x += dx                            // ✗ на удалённом прокси: следующая интерполированная поза перезапишет
if (rep.simulated) node.x += dx         // ✓ двигай только то, что симулирует эта машина

rep.state.hp = 0                        // ✗ на клиенте: SDK перезапишет состоянием сервера
Net.send(Hit, { target: rep.netId })    // ✓ попроси сервер; он меняет состояние

Net.connect({ address: "localhost:40000" })   // ✗ "bad address"
Net.connect({ address: "127.0.0.1:40000" })   // ✓

Смотрите также

  • Аспекты — updateFixed, где живёт общая симуляция
  • Системы — ввод и игровые правила на уровне сцены
  • Физика — CharacterController для игроков
  • Сеть — WebSocket и fetch к обычным веб-серверам
  • Серверные функции — вызовы собственного бэкенда приложения