Мультиплеерные веб-игры: Playroom SDK + Phaser 3 + Discord Activity

Курс посвящён созданию мультиплеерных веб-игр на Phaser 3 с использованием Playroom SDK и запуску их в формате Discord Activity. Вы изучите архитектуру проекта, синхронизацию состояния, работу с комнатами/матчмейкингом и подготовку к публикации и эксплуатации.

1. Обзор стека и подготовка окружения

Обзор стека и подготовка окружения

В этом курсе мы соберём и опубликуем мультиплеерную веб-игру на Phaser 3, добавим сетевое взаимодействие через Playroom SDK, а затем упакуем игру как Discord Activity, чтобы в неё можно было играть прямо внутри Discord.

Эта статья задаёт общую картину стека и приводит окружение в состояние, в котором можно уверенно стартовать разработку.

Что мы будем считать готовым результатом

К концу курса у вас будет:

  • веб-игра на Phaser 3, запускаемая в браузере
  • мультиплеерная логика с комнатами и игроками через Playroom SDK
  • сборка и запуск как Discord Activity
  • понятный локальный workflow: разработка, тестирование, деплой
  • Компоненты стека и их роли

    Ниже кратко о каждом слое и зачем он нужен.

    | Компонент | Зачем нужен | Что вы получите | |---|---|---| | Phaser 3 | игровой движок для 2D в браузере | сцены, спрайты, физика, ввод, таймеры | | Playroom SDK | мультиплеерная надстройка для браузерных игр | комнаты, игроки, синхронизация состояния, подключение/отключение | | Discord Activity | оболочка для запуска вашей веб-игры внутри Discord | доступность игры в каналах Discord и совместный запуск | | Node.js + npm | среда и менеджер пакетов | установка зависимостей и запуск dev/build | | Vite | dev-сервер и сборщик | быстрый hot reload и сборка для продакшена |

    Ссылки на ключевые технологии:

  • Phaser 3
  • Документация Phaser 3
  • Playroom
  • PlayroomKit на npm
  • Node.js
  • Vite
  • Discord Activities Overview
  • !Диаграмма того, как Phaser, Playroom и Discord Activity связаны между собой

    Термины, которые будем использовать

    Чтобы дальше не спотыкаться о слова:

  • Клиент: браузер игрока, где работает игра.
  • Комната: изолированная сессия, куда подключаются игроки для совместной игры.
  • Состояние игры: данные, которые должны быть согласованы между игроками (например, позиции, счёт, выбранные персонажи).
  • Dev-сервер: локальный сервер для разработки с быстрым обновлением.
  • Сборка: получение оптимизированных файлов для публикации.
  • Предварительные требования

    Аккаунты и доступы

    Вам понадобятся:

  • аккаунт Discord (для тестирования Activity)
  • доступ к Playroom (чтобы получить идентификатор/ключ проекта, который будет использовать игра)
  • Если вы пока не планируете Activity, всё равно можно начать с браузерной версии: Phaser + Playroom.

    Установка инструментов

    1) Установите Node.js LTS с сайта Node.js

    2) Проверьте версии в терминале:

    3) Рекомендуемые редакторы и утилиты:

  • VS Code
  • Git
  • Создаём проект: Phaser 3 + Vite

    Мы используем Vite, потому что он даёт быстрый запуск, удобную структуру и предсказуемую сборку.

    1) Создайте приложение на Vite:

    2) Установите Phaser:

    3) Запустите dev-сервер:

    Откройте адрес, который выведет Vite (обычно это http://localhost:5173).

    Добавляем Playroom SDK в проект

    Playroom SDK будет отвечать за сетевую часть: подключение игроков к одной комнате и передачу общего состояния.

    1) Установите пакет:

    2) Заведите конфигурацию через переменные окружения.

    Создайте файл .env в корне проекта:

    Пояснения:

  • VITE_ префикс нужен Vite, чтобы переменная была доступна в коде клиента.
  • VITE_PLAYROOM_GAME_ID это значение, которое вы получите в панели/настройках Playroom проекта.
  • Важно: .env обычно не коммитят в публичный репозиторий. Позже мы добавим .env.example.

    Подготовка к Discord Activity

    Discord Activity в большинстве случаев означает, что ваша игра будет:

  • открываться внутри Discord-клиента
  • работать как веб-приложение (обычно встраивание через iframe)
  • требовать доступного извне URL для тестирования
  • На этапе подготовки окружения нужно понимать два практических ограничения.

    Публичный HTTPS URL для тестов

    Discord удобнее тестировать, когда ваш dev-сервер доступен по публичному адресу через HTTPS.

    Типичный подход:

  • поднимаете локально npm run dev
  • пробрасываете порт наружу через туннель
  • Популярные инструменты:

  • ngrok
  • Cloudflare Tunnel
  • Мы вернёмся к этому, когда будем реально запускать Activity. Сейчас важно заранее знать, что одного localhost часто недостаточно.

    Ограничения встраивания

    Встраивание в Discord накладывает ограничения, которые важно помнить заранее:

  • политика безопасности браузера и Discord-контейнера может влиять на доступ к некоторым API
  • нужно внимательно относиться к загрузке ресурсов, CORS и источникам
  • На практике это означает: делаем сборку и серверинг максимально стандартными, без нестабильных хаков.

    Рекомендуемая структура проекта

    Сразу договоримся о базовой структуре, чтобы дальше курс был предсказуемым:

  • src/ — исходники
  • src/game/ — всё, что относится к Phaser (сцены, объекты, ассеты)
  • src/net/ — обёртки над Playroom (комната, игрок, синхронизация)
  • public/ — статические файлы
  • Vite позволяет удобно импортировать модули, а позже мы добавим разделение на сцены и сетевые слои так, чтобы код не смешивался.

    Проверочный чеклист перед продолжением

    У вас должно получиться:

  • npm run dev запускает проект
  • Phaser установлен и импортируется без ошибок
  • PlayroomKit установлен
  • создан .env с VITE_PLAYROOM_GAME_ID
  • вы понимаете, что для Discord Activity потребуется публичный HTTPS URL
  • Как дальше будет устроен курс

    Дальше мы пойдём по такой логике:

    1) базовая игра на Phaser: сцены, объекты, управление 2) подключение Playroom: комната, игроки, минимальная синхронизация 3) проектирование сетевого состояния: что синхронизировать и как не сломать игру 4) упаковка и запуск в Discord Activity: требования, сборка, тестирование 5) полировка: стабильность, переподключения, простая античит-логика на уровне дизайна

    В следующей статье начнём с основы, на которую затем «наденем» мультиплеер.

    2. Основы Phaser 3: сцены, ресурсы и игровой цикл

    Basics of Phaser 3: scenes, assets, and the game loop

    In the previous article you set up a Vite project, installed Phaser 3 and PlayroomKit, and verified your environment. Now we will build the single-player foundation of the game: a minimal Phaser structure that is clean, modular, and ready to receive multiplayer logic later.

    The main goal of this lesson is to make you comfortable with three core Phaser concepts:

  • Scenes as the unit of game flow
  • Asset loading so every client can build the same world
  • The game loop so movement and animations behave predictably
  • What you will build in this lesson

    By the end of this article you will have:

  • a Phaser game booting inside a Vite app
  • at least one scene with preload, create, and update
  • assets loaded via Phaser’s Loader
  • a controllable player object and a simple camera setup
  • This is intentionally offline for now. In the next lessons we will connect Playroom rooms and players to these same concepts.

    Phaser mental model

    Phaser is a framework that:

  • creates a canvas (WebGL or Canvas renderer)
  • runs a repeating update cycle (the game loop)
  • lets you organize gameplay into scenes
  • provides systems: input, animation, audio, physics, cameras
  • The most important architectural decision early on is: keep your Phaser code organized so networking can be layered in later.

    !Phaser runtime structure and the Scene lifecycle

    Project structure for Phaser code

    A practical baseline (you can adjust later):

  • src/main.js or src/main.ts for app entry
  • src/game/game.js for Phaser game creation
  • src/game/scenes/ for scenes
  • public/assets/ for images, audio, tilemaps
  • This aligns with the structure introduced in the previous article and keeps Phaser separated from future networking code in src/net/.

    Creating the Phaser game instance

    Create a file src/game/game.js:

    Key config fields you should understand:

  • type: Phaser.AUTO chooses WebGL when possible, otherwise Canvas.
  • parent tells Phaser where to insert the canvas.
  • width and height define the base resolution. You can later add scaling strategies.
  • physics enables Arcade Physics. For multiplayer prototypes it is often the simplest starting point.
  • scene is the list of scenes available to the game.
  • Now call it from src/main.js:

    Scenes: the building blocks of your game

    A scene is a self-contained piece of the game such as:

  • boot/loading
  • main menu
  • gameplay
  • results screen
  • For multiplayer, scenes are also an excellent boundary for:

  • connecting to a room when a gameplay scene starts
  • cleaning up subscriptions and listeners when the scene ends
  • The Scene lifecycle: preload, create, update

    A common minimal scene has:

  • preload(): load assets (images, spritesheets, audio)
  • create(): build the world (place sprites, create UI, set up input)
  • update(time, delta): per-frame logic (movement, animations, checks)
  • Create src/game/scenes/MainScene.js:

    What to notice here:

  • We load images by key (like "player") and then refer to them by the same key in create().
  • We use Arcade Physics so the player has velocity and collision with world bounds.
  • We store input in this.cursors once in create() and read it in update().
  • We normalize diagonal movement so the player does not move faster on diagonals.
  • Asset loading basics

    Phaser’s Loader is designed to:

  • load files asynchronously
  • store them in a cache (by key)
  • allow you to use the cached asset immediately after loading
  • Where to put assets in a Vite project

    A simple rule:

  • put files into public/assets/...
  • reference them with an absolute path like "/assets/player.png"
  • This works well for a Discord Activity too, because you typically deploy a static build where public/ files are served directly.

    Common loader calls you will use

  • this.load.image(key, url)
  • this.load.spritesheet(key, url, { frameWidth, frameHeight })
  • this.load.audio(key, [urls])
  • this.load.tilemapTiledJSON(key, url)
  • Phaser loader documentation:

  • Phaser 3 Loader Plugin
  • Loading progress and a dedicated loading scene

    As your game grows, you will want a separate scene that:

  • preloads everything
  • displays a progress bar
  • starts the gameplay scene only when loading is done
  • For now, keep loading inside MainScene to stay focused. We will split scenes when we start adding networking and menus.

    The game loop and delta

    Phaser calls update(time, delta) many times per second.

  • time is the total elapsed time since the game started (in milliseconds).
  • delta is the time between frames (also milliseconds).
  • Why delta matters:

  • on a fast machine you may get more frames per second
  • on a slow machine you may get fewer frames per second
  • If your movement logic is purely “set velocity to a constant”, Arcade Physics will handle time stepping for you. But when you implement your own motion (for example moving a UI cursor, or interpolating network positions), you often want to multiply by delta.

    A typical pattern for manual movement is:

  • compute speed in units per second
  • convert delta to seconds by using delta / 1000
  • apply position += speed * (delta / 1000)
  • You do not need to implement this right now, but you should recognize the pattern because it becomes important in multiplayer smoothing.

    Cameras and resolution basics

    Phaser supports cameras out of the box. In a multiplayer game the camera usually:

  • follows the local player
  • does not follow remote players
  • We already used:

  • this.cameras.main.startFollow(player, ...)
  • Later, when multiple players exist, you will keep a clear separation:

  • local controlled entity is followed by the camera
  • remote entities are still rendered but not used as camera targets
  • A multiplayer-friendly approach to game objects

    Even before Playroom, structure your game so every entity can be expressed by:

  • an id (for multiplayer this becomes player id)
  • a visual (sprite)
  • a state (position, direction, animation)
  • A practical next step is to create a small Player wrapper class that:

  • owns a sprite
  • exposes setInput(...) for local control
  • exposes applyRemoteState(...) for remote updates
  • We will implement this when we introduce Playroom players, so that local and remote players share the same rendering code.

    Common mistakes at this stage

  • Loading assets in create() instead of preload().
  • Creating input handlers every frame inside update().
  • Mixing scene responsibilities, for example networking logic scattered across multiple scenes.
  • Assuming localhost behavior is identical inside Discord. Keep the runtime simple and standard.
  • What you should have ready before moving on

  • Your Vite app starts and shows a Phaser canvas.
  • MainScene loads at least one image from public/assets/.
  • You can move a player sprite with arrow keys.
  • You understand what preload, create, and update are responsible for.
  • Next, we will introduce a minimal networking layer with Playroom: joining a room and spawning one sprite per connected player.

    3. Интеграция Playroom SDK: комнаты, игроки, события

    Integration Playroom SDK: rooms, players, events

    In the previous lesson you built a clean single-player foundation in Phaser 3: a scene, asset loading, and a controllable sprite. Now we will add the multiplayer layer using Playroom SDK (PlayroomKit).

    The goal of this article is not to build a full netcode system, but to establish a solid baseline:

  • connect all clients to the same room
  • react to players joining and leaving
  • synchronize a minimal shared state (positions)
  • You will keep your Phaser code readable by isolating networking in src/net/, as planned in the first article.

    What Playroom gives you

    PlayroomKit is a browser-friendly multiplayer SDK. In practice, it provides:

  • a way to connect the current client to a multiplayer room
  • a list of players in the room
  • events when players join/quit
  • simple state synchronization for small pieces of data
  • Official links:

  • Playroom
  • PlayroomKit (npm)
  • > Design idea for this course: Phaser is responsible for rendering and input, and Playroom is responsible for who is in the session and what shared state they have.

    !How clients connect to a room and exchange minimal state

    Step zero: be clear about what must be synchronized

    For a first playable multiplayer prototype, synchronize only what is necessary:

  • each player’s x and y position
  • Everything else stays local:

  • camera following
  • UI hints
  • debug overlays
  • Later you can add more shared data (animation, facing direction, score), but starting small avoids typical mistakes like syncing too much, too often.

    Connecting to a room with insertCoin

    Playroom connection should happen once, before you start creating networked entities.

    A practical approach is to connect in the app entry (src/main.js) and only then create the Phaser game.

    Create or edit src/main.js:

    Notes:

  • insertCoin(...) connects the current browser tab to a Playroom room.
  • import.meta.env.VITE_PLAYROOM_GAME_ID comes from your .env created in the first article.
  • If you forget to await this call, you may create your scene before the multiplayer session is ready.
  • Vite env variables reference:

  • Vite env variables and modes
  • Networking wrapper: keep Playroom logic out of Phaser scenes

    Create src/net/playroom.js:

    Why do this?

  • your Phaser scene stays focused on game logic
  • later, when Discord Activity constraints appear, you have a single networking entry point to adjust
  • Creating networked player entities in Phaser

    Now you will modify MainScene so it can:

  • create a sprite when a player joins
  • remove a sprite when a player quits
  • drive the local sprite from keyboard input
  • update remote sprites from synchronized state
  • Edit src/game/scenes/MainScene.js.

    Preload stays the same

    You still load assets the same way:

    Create: subscribe to join/quit, create sprites, set camera

    Understanding the three key multiplayer concepts

    Room

    A room is a shared session. All players in the same room:

  • receive join/quit events about each other
  • can read and write synchronized state
  • From the Phaser perspective, a room answers one question:

  • which other players should exist in my scene right now?
  • Player

    A player represents one connected client. For each player you typically keep two things:

  • a network object (Playroom player)
  • a render object (Phaser sprite)
  • That is why the example keeps two maps:

  • this.players stores player objects
  • this.playerSprites stores sprites by the same player.id
  • This is the simplest stable pattern for multiplayer rendering.

    Events

    At this stage, the most important events are:

  • onPlayerJoin(...) to spawn entities
  • player.onQuit(...) to despawn entities
  • Treat these as lifecycle events for game entities:

  • join event creates the entity
  • quit event destroys the entity
  • Later you will add more advanced events (ready checks, match start, scoring), but you can already implement many of them using synchronized state keys.

    Practical pitfalls and how to avoid them

  • Creating a sprite before you have player state
  • - Always define an initial x and y state for a joining player so everyone has something to render.
  • Letting remote players use physics movement
  • - Remote players should usually be rendered from network state, not simulated locally.
  • Mixing networking and gameplay logic everywhere
  • - Keep Playroom calls behind src/net/ and keep your Phaser scene mostly about sprites and input.
  • Assuming the local player sprite always exists
  • - In real multiplayer, connections can be delayed. Defensive checks like if (!sprite) return; keep your game stable.

    What you should have working now

  • Two browser tabs joined to the same session show two differently tinted player sprites.
  • Moving in one tab updates the remote sprite in the other tab.
  • Closing a tab removes that player’s sprite in the remaining tab.
  • Next, you will design a more deliberate shared state model (not just x/y), and start preparing the project for Discord Activity constraints and testing.

    4. Синхронизация состояния и сетевой геймплей

    State synchronization and network gameplay

    You already have a working baseline: Phaser renders the world and controls the local sprite, while Playroom connects players, fires join/quit events, and lets you share small pieces of state (x, y).

    Now we will turn that baseline into network gameplay that feels stable:

  • you will design a state model (what you sync, where it lives, and who writes it)
  • you will stop sending x/y every frame and switch to a network rate
  • you will add interpolation so remote players move smoothly
  • you will introduce ownership rules to avoid state fights
  • !How input becomes shared state and then becomes smooth remote movement

    What “state synchronization” means in this course

    In a multiplayer game, you need all clients to agree on some data.

  • Local-only data is allowed to differ (camera, UI hints, debug text).
  • Shared state must be consistent enough to keep gameplay fair and understandable (positions, scores, ready status, match phase).
  • In PlayroomKit, the simplest shared state you already used is per-player key/value data:

  • player.setState("x", ...)
  • player.getState("x")
  • The key design decision is not the API call. It is choosing:

  • What to synchronize
  • How often to synchronize
  • How to present remote state smoothly
  • A practical state model: inputs vs positions

    There are two common approaches:

  • Sync positions
  • Sync inputs
  • For this course (Phaser + Playroom + Discord Activity), start with syncing positions because it is predictable, easy to debug, and works well for casual multiplayer prototypes.

    Later, you can evolve toward input sync if you need stricter fairness.

    Minimal state you should sync now

    For each player, sync:

  • x, y (position)
  • vx, vy (velocity) optional but helpful
  • seq (a sequence number for “latest update wins”)
  • You do not need to sync everything.

  • Do not sync the camera.
  • Do not sync UI.
  • Do not sync physics collisions for remote players.
  • Ownership rules: who is allowed to write which state

    Without rules, two clients can overwrite the same state and cause jitter.

    Use this simple rule:

  • Each player is the owner of their own movement state
  • Meaning:

  • you only call myPlayer().setState(...) for your player
  • you never call setState(...) for other players
  • In your join handler you may still initialize missing state keys for the joining player, but you should treat that as spawn initialization only.

    Stop sending every frame: add a publish rate

    Your previous implementation published x/y inside update(). That can run ~60 times per second, which is often unnecessary and can increase jitter (because small floating point differences get transmitted).

    A stable baseline is to publish movement state at a fixed rate, for example 10–15 times per second.

    State keys as constants

    Create src/net/stateKeys.js:

    A small helper to quantize values

    Quantization reduces noise and bandwidth. For top-down movement, rounding to integers is usually fine.

    Create src/net/quantize.js:

    A dedicated networked player entity

    Right now, your scene owns both rendering and network logic. To keep the code scalable, introduce a small wrapper class for a networked player.

    Create src/game/entities/NetPlayer.js:

    Key idea:

  • local player: uses physics + input
  • remote player: does not use physics simulation; it renders toward targets
  • Updating the scene: spawn, publish timer, interpolate

    Below is a consolidated version of changes you can apply to src/game/scenes/MainScene.js.

    Important behaviors:

  • local movement is still handled every frame
  • publishing happens on a Phaser timer (fixed rate)
  • remote players are interpolated every frame
  • Interpolation: why it works and what it costs

    Without smoothing, remote players “teleport” from one network update to the next.

    Interpolation fixes this by gradually moving the remote sprite toward the last known target.

    Tradeoff:

  • the remote player appears slightly delayed compared to the owner
  • For casual games, this is acceptable and often preferred over jitter.

    !Why interpolation makes remote movement look continuous

    Designing shared state that scales beyond movement

    Once movement works, you will want higher-level gameplay state. A good pattern is to separate:

  • Per-player state: position, direction, ready flag
  • Match state: phase (lobby/playing/results), timer, score limit
  • Even if you keep using per-player keys for the early prototype, think in terms of categories.

    A practical set of keys for a small arcade game:

  • x, y, vx, vy
  • dir (facing direction)
  • anim (string or small integer)
  • ready (boolean)
  • team (small integer)
  • Keep values small and stable:

  • prefer integers
  • avoid large nested objects unless you truly need them
  • Handling late join and respawn

    Late join means a new client connects when others are already moving.

    To support this, ensure:

  • Each player sets initial x/y once when they spawn.
  • Remote clients can render a player using whatever state exists right now.
  • If you later add respawn, treat it as a controlled state change:

  • owner sets x/y to a spawn point
  • optionally sets alive = true/false
  • Debugging multiplayer state

    When multiplayer feels wrong, you need visibility.

    A simple debug overlay you can add (local-only) is:

  • local publish rate
  • your current seq
  • number of connected players
  • Keep the overlay local. Do not sync debug state.

    Common mistakes in state synchronization

  • Publishing from update() every frame
  • Trying to simulate remote players with local physics
  • Syncing too much too early (animation frames, camera state, random seeds, particle positions)
  • No ownership rule (multiple clients writing the same concept)
  • No initialization (late joiners see remote players at (0, 0) or not at all)
  • References

  • PlayroomKit package on npm
  • Phaser 3 API documentation
  • Phaser 3 “Time” concept (timers and events)
  • What you should have working after this lesson

  • The local player feels the same as before.
  • Remote players move smoothly rather than teleporting.
  • State is published at a fixed rate (for example 12 Hz) instead of every frame.
  • Your code clearly separates:
  • - local simulation and input - network publishing - remote rendering (interpolation)

    This is the foundation you will reuse when packaging as a Discord Activity, where stable performance and predictable networking behavior matter even more.

    5. Интеграция Discord Activity: запуск, SDK и UX

    Discord Activity integration: launch, SDK, and UX

    You already have a multiplayer web game running in the browser:

  • Phaser 3 renders the world and handles input
  • Playroom connects players to a room and synchronizes movement state
  • you publish state at a fixed rate and smooth remote movement
  • Now you will package the same game as a Discord Activity so it can run inside Discord and feel good there.

    What a Discord Activity is in practice

    A Discord Activity is a web app that Discord opens for multiple users inside the Discord client. Your game still runs in a browser environment (embedded), but you gain:

  • an Activity instance concept (a shared session container in Discord)
  • access to Discord context through the Discord Embedded App SDK
  • UX constraints you must respect: sizing, focus, networking, and embedding rules
  • !How Discord, your hosted game, Phaser, and Playroom connect

    Prerequisites you must satisfy

    A public HTTPS URL

    Discord needs to load your Activity from a URL that is reachable from the internet and uses HTTPS.

    Options during development:

  • ngrok
  • Cloudflare Tunnel
  • In production you typically deploy a static build (Vite) to a host that serves HTTPS by default.

    Your app must allow embedding

    Discord embeds your game, so your hosting must not block being rendered in an iframe.

    Practical notes:

  • do not set response headers that forbid iframing (for example, an overly strict X-Frame-Options)
  • avoid CSP rules that block frame-ancestors for Discord
  • If you do not control headers (some hosts do), prefer a platform that lets you configure them.

    Setting up a Discord application for Activities

    Create and configure a Discord app:

  • Go to Discord Developer Portal
  • Create New Application
  • Find the Activities section and enable/configure Activity settings (naming may vary as Discord updates the UI)
  • Add your Activity URL pointing to your public HTTPS endpoint
  • Reference:

  • Discord Activities Overview
  • What you should understand conceptually:

  • your Discord app has a Client ID
  • the Activity points Discord to a URL (your hosted web build)
  • the embedded app can optionally authenticate and call Discord APIs via the SDK
  • Adding the Discord Embedded App SDK

    Discord provides an SDK designed specifically for embedded Activities.

    Sources:

  • Discord Embedded App SDK repository
  • Discord Embedded App SDK on npm
  • Install it:

    Add environment variables

    Create or extend your .env:

    Keep the same rules as before:

  • the VITE_ prefix is required for Vite client-side access
  • do not commit secrets; Client ID is not a secret, but access tokens are
  • Detecting Discord vs normal browser

    Your game should still run normally in a browser tab. Discord integration should be an optional layer.

    Create src/discord/discord.js:

    What this gives you:

  • when running as a normal web game, initDiscordSdk() returns null
  • when running inside Discord, the SDK should initialize and you can read Activity context
  • Boot flow: initialize Discord, then Playroom, then Phaser

    You already await insertCoin(...) before creating the Phaser game. Now you extend the boot sequence:

  • initialize Discord SDK (if available)
  • decide a room identity strategy (important for Activities)
  • connect Playroom
  • start Phaser
  • Update src/main.js:

    Why the order matters:

  • Discord SDK gives you Activity context that you may want to use for matchmaking
  • Playroom must connect before you spawn networked entities
  • Phaser starts last, once the environment is ready
  • Room UX inside Discord: avoiding accidental fragmentation

    In a normal browser test, you often open two tabs and get them into the same session manually.

    In Discord, users expect:

  • clicking Start Activity puts everyone into the same shared session
  • joining from the same Activity instance should not randomly create separate rooms
  • That means you need a stable way to compute a room identifier based on Discord context.

    Practical strategies:

  • derive a room key from the Activity instance identifier (preferred)
  • derive a room key from the channel context
  • use a host-generated short code and pass it via URL query
  • Important design rule:

  • room identity should be deterministic and visible in debugging
  • Even if you keep Playroom’s default room behavior initially, plan to add deterministic room selection when you start real Discord playtests.

    Authentication: when you actually need it

    Many Activities do not need Discord OAuth immediately.

    You need authentication only if you want to:

  • show the user’s Discord name/avatar inside the game
  • call Discord APIs that require an access token
  • If you only need multiplayer gameplay via Playroom, you can defer OAuth and still ship a working Activity.

    When you do want to add auth, the Embedded App SDK supports an authorize-and-authenticate flow. You can study the current API shape in the official SDK repo:

  • Discord Embedded App SDK repository
  • A key architectural note:

  • exchanging an authorization code for an access token should be done on a backend, not in the client
  • For this course’s scope, treat Discord auth as an optional enhancement, not a blocker.

    Phaser UX in an embedded container

    Discord can resize the Activity viewport. Your Phaser game should adapt smoothly.

    Use Phaser Scale Manager

    Update your Phaser game config in src/game/game.js to support resizing:

    What this changes:

  • Phaser will match the canvas size to its parent container
  • your game becomes more robust inside Discord’s resizable layout
  • Keep UI readable at different sizes

    Do not hardcode UI positions assuming a fixed resolution. A minimal, practical approach:

  • place UI using margins from the top-left
  • on resize, re-evaluate positions if you have complex UI
  • For this course’s prototype text overlay, keep it simple but verify it does not go off-screen.

    Input and focus UX

    Inside Discord, keyboard focus can be lost more often than in a normal tab.

    Make your Activity resilient:

  • if input stops working, clicking the game area should restore focus
  • avoid relying on browser behaviors that may be restricted in embeds
  • A practical Phaser-side pattern:

  • ensure your canvas container is clickable
  • avoid opening popups during gameplay
  • Performance and networking UX

    Discord users often run Activities while voice/video is active. Your game should behave well under fluctuating performance.

    Keep the multiplayer model you already implemented:

  • publish at a fixed rate (for example, 12 Hz)
  • interpolate remote players
  • quantize positions to reduce noise
  • Discord-specific practical tips:

  • keep your publish rate stable rather than trying to send every frame
  • reduce asset size to speed up initial load (important in social contexts)
  • A minimal Discord-specific overlay

    Even without OAuth, you can improve UX by clearly indicating where the game is running.

    Add a local-only debug label in your scene:

  • Web mode when not in Discord
  • Discord Activity mode when Discord SDK initialized
  • Implementation approach:

  • store a runtimeMode flag in a small module
  • read it from the scene and show it as text
  • This helps debugging and reduces confusion during playtests.

    Common integration failures and how to debug them

    The Activity shows a blank screen

    Typical causes:

  • the URL is not publicly reachable over HTTPS
  • assets are loaded from incorrect paths
  • the host blocks iframing
  • What to do:

  • open the same URL in a normal browser and check the console
  • verify all assets are under public/assets/... and referenced as "/assets/..."
  • test with a different host if you suspect embedding headers
  • Multiplayer works in browser but not in Discord

    Typical causes:

  • the Activity instance splits users into different rooms (room identity mismatch)
  • network state is being spammed or jitter is amplified by performance differences
  • What to do:

  • log and display your current room identifier in a debug overlay
  • keep publish rate fixed and do not publish every frame
  • What you should have after this lesson

  • your game still runs as a normal web app
  • you can provide a public HTTPS URL for Discord
  • you have the Discord Embedded App SDK installed and initialized safely
  • your Phaser canvas resizes correctly in an embedded container
  • you understand the key UX requirements of Discord Activities: deterministic session identity, resizing, focus, and stable networking
  • In the next steps of a real project, you would typically:

  • implement deterministic room mapping from Discord Activity instance to Playroom room
  • optionally add Discord OAuth to personalize players
  • polish lobby UX so starting and joining an Activity feels instant and obvious
  • 6. Лобби, матчмейкинг, приглашения и социальные механики

    Lobby, matchmaking, invites, and social mechanics

    You already have:

  • a Phaser 3 game structure (scenes, assets, update loop)
  • Playroom-connected multiplayer with per-player state (x, y, vx, vy, seq)
  • smoother remote movement using publish rate and interpolation
  • optional Discord Embedded App SDK initialization and resize-friendly canvas
  • Now you will build the part that makes multiplayer feel like a product, not just a tech demo:

  • a lobby scene before gameplay
  • matchmaking rules (how players end up together)
  • invites (how to bring friends into the same session)
  • lightweight social mechanics (ready, quick reactions, simple moderation UX)
  • !The end-to-end flow from launch to lobby to match

    What problems this lesson solves

    Without a lobby and deterministic session identity, multiplayer breaks in very predictable ways:

  • players open the game and end up in different rooms without realizing it
  • there is no clear moment when the match starts
  • late joiners appear inside a running match without context
  • friends cannot reliably join each other
  • So the goal is to make session behavior explicit:

  • every client can compute and display the same lobby key
  • the lobby shows who is here
  • the lobby exposes a ready mechanic
  • the match starts only when the lobby conditions are met
  • Session identity: the core of matchmaking

    In this course, matchmaking is not an advanced ranking system. It is simply the rule that answers:

  • How do multiple users end up in the same Playroom session?
  • A practical approach is to derive a deterministic lobbyKey from runtime context.

    Sources of truth for a lobby key

    Use the first available source in this priority order:

  • Discord Activity instance context
  • URL query parameter (shareable web link)
  • Fallback (create a new room key)
  • Extracting Discord context safely

    Discord Activities commonly provide identifiers via URL query parameters (for example instance_id, channel_id). Treat them as hints: they are great for deterministic grouping, and they work even if your Discord SDK layer is disabled.

    Create src/runtime/sessionKey.js:

    What this gives you:

  • a deterministic start condition: everyone ready and host elected by lowest id
  • a lobby UX that works both in web and in Discord
  • a low-friction social mechanic: reactions
  • Transitioning to the match without desync

    The simplest safe rule for starting:

  • only the elected host calls scene.start("GameScene")
  • everyone else will also transition because they run the same shouldStartMatch() logic and see the same ready states
  • This works because readiness is synchronized and host election is deterministic.

    If you later need a strict countdown, you can implement it as:

  • Host sets a startAt timestamp in their own player state.
  • Others read host startAt and start exactly when Date.now() >= startAt.
  • This course avoids that extra step unless you need it.

    Invites: how friends join the same session

    Invites are mostly about making the lobbyKey portable.

    Web invite links

    If you run outside Discord, the simplest invite is:

  • share a URL containing ?room=CODE
  • Add a small helper src/runtime/inviteLink.js:

    To surface it in the lobby, you can display the invite URL as text and provide a “copy” button in your surrounding UI (outside Phaser) or just keep it as debug for now.

    Discord invites

    Inside Discord, the social mechanism is often native:

  • a user starts an Activity in a voice channel
  • others join that Activity instance from Discord UI
  • Your job is to prevent fragmentation by ensuring your lobbyKey uses the Activity instance identifier when available.

    Reference:

  • Discord Activities Overview
  • Discord Embedded App SDK (npm)
  • Social mechanics that matter in small multiplayer games

    You do not need complex chat to make a lobby feel alive. The following mechanics are cheap and high impact.

    Ready and role clarity

    Make it obvious:

  • who is ready
  • who is not
  • who is the host (even if implicit)
  • In the example above, host is not explicitly labeled, but you can easily add "host" marker for the lowest id.

    Lightweight reactions

    The emoji state key is intentionally short-lived:

  • it communicates emotion
  • it does not require moderation tools
  • it is low bandwidth
  • Soft moderation UX

    Even without backend authority, you can improve safety:

  • allow local mute of reactions (client-side filter)
  • allow local hide of a player (client-side ignore list)
  • This does not prevent abuse globally, but it improves user experience and is realistic for lightweight prototypes.

    Common pitfalls

    Starting the match on every client

    If every client transitions independently without deterministic rules, you can get:

  • some clients in lobby, some in match
  • late joiners stuck in lobby forever
  • Fix:

  • use deterministic host election plus synchronized readiness
  • Using random room selection inside Discord

    If your room identity is not tied to the Activity instance, then:

  • two users in the same channel might still split into different rooms
  • Fix:

  • derive lobbyKey from instance_id when present
  • display it in the lobby so playtesters can confirm
  • Over-syncing social state

    Do not synchronize:

  • full chat logs
  • large player profiles
  • frequent cosmetic updates
  • Keep social state small, discrete, and intentional.

    What you should have after this lesson

  • a LobbyScene that lists players and shows ready status
  • deterministic match start behavior using a host election rule
  • a stable session identity (lobbyKey) derived from Discord or URL
  • a simple invite strategy for web (?room=...) and a clear model for Discord instances
  • at least one lightweight social mechanic (reactions)
  • References

  • Playroom
  • PlayroomKit (npm)
  • Phaser 3 Documentation
  • Discord Activities Overview
  • Discord Embedded App SDK (npm)
  • 7. Тестирование, деплой, мониторинг и безопасность

    Тестирование, деплой, мониторинг и безопасность

    Вы уже собрали основу мультиплеера:

  • Phaser 3 рендерит и управляет сценами
  • Playroom SDK соединяет игроков, даёт события join/quit и синхронизацию state
  • в Discord Activity вы учитываете встраивание, ресайз и особенности UX
  • у вас есть лобби, детерминированная идентичность сессии и старт матча
  • Следующий шаг — довести проект до уровня, который можно стабильно тестировать, безопасно публиковать и поддерживать после релиза.

    !Общая картина: как изменения в коде превращаются в релиз и как вы ловите проблемы в продакшене

    Что мы считаем хорошим результатом

    К концу этой статьи у вас должно быть:

  • воспроизводимый процесс сборки vite build и локального предпросмотра vite preview
  • базовый набор тестов, которые ловят поломки до деплоя
  • понятный способ тестировать игру внутри Discord Activity
  • продакшен-деплой на HTTPS с учётом требований встраивания (iframe)
  • мониторинг ошибок и минимальная телеметрия
  • практики безопасности для клиентской мультиплеерной игры
  • Тестирование: что реально нужно для мультиплеерной веб-игры

    Тестирование в таких проектах полезно разделять по уровням. Важно не пытаться покрыть тестами всё, а закрыть самые дорогие ошибки: регрессии в логике сессии, лобби и синхронизации.

    Виды тестов и где они применяются

    | Уровень | Что проверяет | Что выгоднее тестировать в этом курсе | Инструменты | |---|---|---|---| | Юнит-тесты | чистые функции и маленькие модули | генерация lobbyKey, парсинг query, квантование, правила host election | Vitest | | Интеграционные | несколько модулей вместе | запуск boot-flow: Discord init (как optional), выбор session key, вызов insertCoin | Vitest + моки | | E2E | поведение в браузере | загрузка страницы, наличие canvas, базовая навигация сцен, работа ?room= | Playwright | | Ручные мультиклиентные | синхронизация между вкладками/браузерами | join/quit, ready, старт матча, движение и сглаживание | 2–4 клиента + разные сети |

    Термин E2E означает end-to-end: тест проходит путь как реальный пользователь в браузере.

    Юнит-тест: пример для session key

    Ваш lobbyKey — ключевой элемент матчмейкинга: он отвечает за то, что люди окажутся в одной сессии.

    Если вы используете модуль вроде src/runtime/sessionKey.js, тестируйте:

  • при наличии instance_id возвращается discord:...
  • при наличии room возвращается web:...
  • при отсутствии обоих создаётся и сохраняется значение в sessionStorage
  • Практически важно: логика должна быть детерминированной и обратимо проверяемой.

    E2E-тесты: минимум, который окупается

    В E2E для этой игры обычно достаточно проверить:

  • страница открывается и не падает
  • Phaser canvas появился
  • лобби сцена отрисовалась и показала session key
  • Если вы делаете ?room=TEST, E2E тест должен убедиться, что session key отображает именно web:TEST.

    Ручное мультиклиентное тестирование: обязательный чеклист

    Это то, что автоматизировать сложно, но можно сделать системным процессом.

  • Откройте игру в двух разных профилях браузера (или один обычный режим + инкогнито)
  • Убедитесь, что оба клиента попали в одинаковую сессию (сверьте session key в лобби)
  • Проверьте join/quit
  • Проверьте ready у обоих игроков
  • Проверьте детерминированный старт матча (один становится host по правилу)
  • Проверьте движение и сглаживание удалённых игроков
  • Проверьте поведение при потере фокуса (клик по canvas возвращает управление)
  • Отдельно полезно проверить разные условия сети:

  • один клиент через мобильный интернет
  • один клиент через VPN
  • один клиент нагружен (в фоне включён тяжёлый процесс)
  • Тестирование Discord Activity: практический подход

    Discord Activity часто ломается не в логике игры, а в окружении:

  • игра запускается в embedded-контейнере
  • нужен публичный HTTPS URL
  • важны заголовки, влияющие на iframe
  • Локальная разработка через публичный HTTPS

    Во время разработки используйте туннель:

  • ngrok
  • Cloudflare Tunnel
  • Смысл: Discord должен открыть ваш dev-сервер по HTTPS.

    Минимальный сценарий проверки в Discord

  • Откройте Activity
  • Проверьте, что UI не «уползает» при ресайзе
  • Проверьте ввод с клавиатуры после переключения окон Discord
  • Проверьте, что все участники одной Activity instance получают один session key
  • Если вы опираетесь на instance_id из query, вы должны показать его в дебаге (локально, без синхронизации), чтобы быстро видеть причину фрагментации сессий.

    Деплой: сборка, хостинг, встраивание и стабильные URL

    Сборка и предпросмотр

    В Vite стандартный цикл такой:

  • build создаёт оптимизированные файлы
  • preview запускает локальный сервер, максимально похожий на продакшен-раздачу
  • Важно: Discord Activity ближе по поведению к preview, чем к dev.

    Базовые требования к хостингу для Discord Activity

    Хостинг должен обеспечивать:

  • HTTPS
  • корректную раздачу статических файлов
  • возможность встраивания в iframe (Discord)
  • Главный риск: заголовки безопасности.

  • X-Frame-Options: DENY сломает встраивание
  • Content-Security-Policy с frame-ancestors 'none' тоже сломает
  • При этом полностью отключать защиту нельзя — нужно настроить её корректно.

    Практика: разрешить встраивание только там, где нужно

    Идея: разрешить iframe-встраивание для Discord, но не открывать игру для встраивания кем угодно.

    Это делается через Content-Security-Policy директиву frame-ancestors.

    Значения и точные домены зависят от текущей схемы Discord, поэтому правило такое:

  • не ставьте X-Frame-Options: DENY
  • используйте Content-Security-Policy и разрешайте нужные источники
  • проверяйте в DevTools, какие origin реально используются внутри Activity
  • Официальная справка по CSP:

  • MDN: Content-Security-Policy
  • MDN: frame-ancestors
  • Выбор хостинга

    Для статической Vite-сборки обычно подходят:

  • Cloudflare Pages
  • Vercel
  • Netlify
  • Критерии выбора:

  • можно ли управлять headers (CSP)
  • удобство превью деплоев (preview URLs)
  • простота HTTPS и кастомного домена
  • Версионирование и обратная совместимость

    У мультиплеера есть особенность: игроки могут оказаться на разных версиях клиента.

    Минимальные правила:

  • держите деплой атомарным (одна версия статики для всех)
  • добавляйте в state модель версионность, если меняете ключи
  • Например, если вы переименовали ready или изменили формат данных:

  • вводите новый ключ рядом со старым на время миграции
  • удаляете старый только после того, как убедились, что все клиенты обновлены
  • CI: автоматическая проверка перед деплоем

    Даже простой CI пайплайн резко снижает количество сломанных релизов.

    Минимальный пайплайн:

  • установка зависимостей
  • линт (если есть)
  • тесты
  • сборка
  • GitHub Actions документация:

  • GitHub Actions
  • Пример workflow (адаптируйте под ваши команды):

    Важно: секреты (токены, приватные ключи) добавляются через Secrets в репозитории, а не в код.

    Мониторинг: как понять, что у игроков всё плохо

    Когда игра в продакшене, ваша главная проблема — невидимые ошибки: у пользователя белый экран, а вы об этом не знаете.

    Что мониторить в первую очередь

  • JS-ошибки и unhandled promise rejections
  • ошибки загрузки ассетов
  • время до появления лобби (условно: «время до первого полезного экрана»)
  • частоту переподключений и неожиданных выходов
  • Sentry как базовый мониторинг ошибок

    Sentry удобен тем, что даёт:

  • сбор ошибок из браузера
  • группировку по типам
  • релизы и сопоставление с версией
  • Ссылки:

  • Sentry for JavaScript
  • Правило безопасности:

  • DSN в клиенте не является секретом
  • но любые токены доступа и приватные ключи в клиенте хранить нельзя
  • Логи в мультиплеере: что логировать, чтобы не утонуть

    Логирование должно быть событийным, а не «каждый кадр».

    Хорошие события:

  • вход в лобби: session key, режим (web/discord)
  • join/quit: player id, текущее число игроков
  • старт матча: кто host, сколько ready
  • ошибки сети: таймауты, невозможность подключиться
  • Плохие логи:

  • позиция игрока каждый тик
  • полный state дамп на каждом обновлении
  • Дашборд здоровья релиза

    Минимальный набор метрик, который можно собирать даже без сложной системы:

  • количество загрузок
  • процент сессий, где лобби отрисовалось
  • количество ошибок на 100 сессий
  • средняя длительность сессии
  • Вначале это может быть просто Sentry + пара кастомных событий.

    Безопасность: реалистичные правила для клиентской мультиплеерной игры

    Важное ограничение: ваш клиент — недоверенная среда. Любой игрок может:

  • открыть DevTools
  • менять значения state
  • подменять запросы
  • Поэтому задача безопасности в таком проекте часто формулируется так:

  • защитить пользователей и инфраструктуру
  • снизить влияние читов на игровой опыт
  • не утечь секретами
  • Переменные окружения и секреты

    То, что начинается с VITE_, попадает в клиентский бандл.

    Значит:

  • VITE_PLAYROOM_GAME_ID допустим
  • VITE_DISCORD_CLIENT_ID допустим
  • любые секреты недопустимы
  • Секреты — это то, что даёт права:

  • access token
  • client secret
  • приватные ключи
  • Если вам нужен обмен OAuth code на access token, это делается на сервере.

    Защита от XSS и аккуратная работа с вводом

    XSS — это внедрение вредоносного скрипта через пользовательские данные.

    Риски в вашем проекте:

  • отображение name игрока
  • отображение любых текстовых полей из синхронизированного state
  • Практическое правило:

  • в Phaser используйте this.add.text(...) как текст, не интерпретируйте строки как код
  • не вставляйте пользовательский ввод в DOM через небезопасные способы
  • Справка:

  • MDN: Cross-site scripting (XSS)
  • Политика безопасности контента (CSP)

    CSP помогает уменьшить ущерб от XSS и нежелательных загрузок.

    Что обычно фиксируют в CSP:

  • откуда можно грузить скрипты
  • откуда можно грузить картинки/аудио
  • кто может встраивать страницу (через frame-ancestors)
  • Справка:

  • MDN: CSP
  • Важно: CSP нужно тестировать в Discord, потому что слишком строгая политика может сломать embedded-запуск.

    Зависимости и supply chain

    Мультиплеерная игра на фронтенде почти всегда тянет много зависимостей.

    Минимальные практики:

  • фиксируйте lockfile (package-lock.json или pnpm-lock.yaml)
  • периодически запускайте npm audit
  • обновляйте зависимости осознанно, особенно SDK
  • Справка:

  • npm audit
  • Защита от спама state и «самострела» производительности

    Даже без злонамеренности игра может сама себе навредить.

    У вас уже есть хорошая практика:

  • фиксированный publish rate (например, 12 Гц)
  • квантование
  • Дополнительно полезно:

  • ограничить частоту смены emoji
  • не синхронизировать большие данные
  • вводить простые локальные лимиты: если игрок шлёт слишком часто, вы игнорируете часть действий на клиенте
  • Это не «настоящая» серверная защита, но это снижает нагрузку и делает поведение предсказуемым.

    Античит как дизайн, а не как магия

    Без авторитарного сервера вы не сможете полностью предотвратить читерство. Но можно уменьшить вред:

  • не делайте соревновательный рейтинг без серверной валидации
  • делайте правила, устойчивые к расхождениям (например, кооператив или party-game)
  • старайтесь, чтобы важные решения были проверяемыми и детерминированными (как host election)
  • Финальный релиз-чеклист

    Перед тем как отправлять Activity людям:

  • vite build и vite preview работают локально
  • по публичному HTTPS URL игра грузится без ошибок в консоли
  • внутри Discord Activity игра не белеет, ресайзится и принимает ввод
  • session key одинаковый у всех участников одной Activity instance
  • лобби: join/quit, ready, старт матча работают стабильно
  • мониторинг ошибок включён и вы видите тестовую ошибку в панели
  • нет секретов в VITE_ переменных и репозитории
  • Если этот чеклист выполнен, проект можно считать пригодным для реальных плейтестов, а не только для локальной демонстрации.