Мультиплеер
Net — серверно-авторитативный мультиплеер для 3D-игр поверх UDP. Один бандл выполняет каждую
роль — офлайн, хост, клиент и выделенный сервер. Сервер симулирует. Каждый клиент отправляет свой
ввод каждый тик, предсказывает своего игрока и показывает всех остальных с интерполяцией.
Replicated помечает узел как сетевую сущность, NetEntity — запись этой сущности на каждой
машине, а NetPlayer — один подключённый игрок. Два других сетевых API закрывают другие задачи:
WebSocket — обычный сокет к веб-серверу (network.md), а серверные
функции — вызовы бэкенда приложения (server-functions.md).
Обзор
// Один бандл, каждая роль: `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) // офлайн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. Офлайн удовлетворяет
обоим, так что игра, написанная под мультиплеер, без изменений работает и в одиночном режиме.
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: booleanNet.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".
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
// сервер
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, отличные от серверных (на практике — другой бандл).
причина отказа никогда не доходит до клиента. Сервер ставит причину в очередь, затем разрывает
соединение до того, как сообщение из очереди отправлено. Так что connect() клиента отклоняется
с "disconnected by server", а reject никогда не срабатывает. player.kick(reason) работает
так же:
- выгнанный клиент получает
disconnectс"disconnected by server"; - событие
leaveсервера и остальные клиенты видят причину"kicked"; - твой текст
reasonне доходит ни до кого.
Игроки
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.
на клиенте читай Net.me и Net.players только после того, как connect() зарезолвился (или в
событии connect). Чтение в офлайне или во время подключения создаёт локального игрока-заглушку,
и сейчас он остаётся в Net.players после приёма как устаревшая запись. То же относится к
сущностям, заспавненным в офлайне: они остаются после connect(), и их id сталкиваются с
сущностями сервера (те прокси никогда не создаются). Уничтожь их до того, как клиент
присоединится.
Сообщения
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)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 КБ;
- сообщение больше отбрасывается молча — ни ошибки, ни события.
Ввод и тик
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.
Реплицируемые сущности
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 раз в секунду.
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 на сервере и на машине владельца. Когда игрок уходит, сервер уничтожает каждую сущность, которой тот владел. Сущность без владельца (ownerId0) принадлежит серверу. - Присоединившийся игрок получает каждую живую сущность. Когда сессия клиента завершается, он уничтожает все прокси, которые создал. Собственные узлы сервера остаются.
- Удалённые сущности интерполируются. Числа смешиваются между двумя снапшотами; булевы и поля
discreteберут значение из нового снапшота целиком (используйdiscreteдля индекса или счётчика). Событиеstateклиента срабатывает с каждым снапшотом для своих сущностей и каждый кадр для интерполируемых. - Спавнит только сервер. На клиенте прикрепление
Replicatedсвоими руками лишь пишет предупреждение в лог. На прокси поля опций аспектаkind/ownerне заданы, так что используйrep.entity.kindиrep.ownerPlayer.
Предсказание и коррекция
Машина владельца выполняет тот же updateFixed с тем же вводом, что и сервер, так что её
собственная сущность движется без round trip. Каждый снапшот сравнивается с предсказанием,
записанным для этого тика. Когда они расходятся, узел сдвигается на разницу на следующем
фиксированном шаге (без перемотки). rep.correction (Vec3) затем держит этот скачок как
визуальное смещение, которое затухает до нуля. Рисуй со смещением и никогда не отдавай его
физике:
// узел — симулируемое тело; видимый меш — дочерний узел, несущий коррекцию
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".
Диагностика и ограничения
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; - вариант
fullAndroid и 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, ввод.
нативная оболочка приложения выбирает наименьший вариант SDK, покрывающий движки, которые
использует её бандл, так что чисто 3D-игра получает 3d, где нет транспорта. Принудительно
задай "variant": "full" под ios / android в app.json для приложения, которому нужен Net.
Подводные камни
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к обычным веб-серверам - Серверные функции — вызовы собственного бэкенда приложения