Build a 3D LAN Multiplayer Game

Build a browser-based 3D shrine game with real-time LAN multiplayer.

Introduction

30 Second Summary

A quick game night loses momentum when every device needs a different app or account. The best shared moments begin when everyone can join from the device already in their hand.

In this project, you will build an original top-down 3D multiplayer shrine battle that runs in browsers across your Windows PC, Android device, and iPhone or iPad. A server-authoritative Node.js match keeps every PlayCanvas Engine client synchronized through Socket.IO over your local Wi-Fi network.

What You'll Build

Picture three browsers showing the same moonlit arena while guardians race for a glowing Spirit Flame toward a first-to-three victory.

By the end of this project, you'll have:

  • A moonlit 3D arena where you guide a stylized guardian with keyboard controls on Windows or touch controls on mobile.
  • A synchronized cross-device match visible in Windows, Android, and iPhone/iPad browsers at the same time.
  • A first-to-three objective loop where every screen shows the same Spirit Flame carrier, team score, winner, and restarted match.
  • Secret Mission: Add a server-authoritative Spirit Dash with Space, mobile double-tap input, and a three-second cooldown enforced for every player.

Are there any prerequisites?

You need a Windows PC, an Android device, and an iPhone or iPad connected to the same trusted Wi-Fi network. Some coding experience helps, but no game-development experience is required.

Before We Start

Before the hands-on work begins, this is your moment to define the original game you are committing to build. One synchronized Spirit Flame match between Azure and Crimson guardians gives you a clear test of the real-time multiplayer architecture.

Set Up Your Windows Game Lab

You have defined the Spirit Shrine Clash rules. Those rules now need a dependable place to become a working multiplayer game.

A reliable runtime prevents installation issues from interrupting your first visible arena. This step prepares your editor, test devices, and local project.

In this step, get ready to:
  • Install the Windows development tools needed for the game, including Node.js v24.20.0 LTS plus Visual Studio Code.
  • Prepare Microsoft Edge, Google Chrome, and Safari on one trusted Wi-Fi network.
  • Create the project structure with its pinned npm dependencies.
Install the Windows tools

Node.js runs the game server you build later. npm installs the rendering and multiplayer libraries listed in your project.

Visual Studio Code gives you one place to edit every file. Windows PowerShell provides the commands that create and verify your setup.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Check which Node.js version is currently available by running this command:
node --version

What does this check do?

This command asks the installed Node.js runtime to print its version. The result determines which setup path you need.

✔️ I see the required version

Good start. Node.js v24.20.0 LTS is already available to your PowerShell sessions.

ⓧ I see a different version

Your current runtime does not match the project version. Installing the pinned release keeps your results aligned with the project.

  • Visit the official Node.js download page in your Windows browser.
  • Download Node.js v24.20.0 LTS for Windows.
  • Run the downloaded installer.
  • Keep the default installation options so npm is included.
  • Close every open PowerShell window after the installation finishes.

The fresh-terminal check at the end of this step confirms that Windows uses the new runtime.

ⓧ Command not found

Windows cannot currently find Node.js. Installing the pinned LTS release adds the runtime plus npm.

  • Visit the official Node.js download page in your Windows browser.
  • Download Node.js v24.20.0 LTS for Windows.
  • Run the downloaded installer.
  • Keep the default installation options so npm is included.
  • Close every open PowerShell window after the installation finishes.

The fresh-terminal check at the end of this step confirms that Windows can now find the runtime.

  • Open a fresh PowerShell window through Windows search if you closed the previous window.
  • Check whether the Visual Studio Code command is available by running:
code --version

What does this check do?

Visual Studio Code adds the code command to the Windows path through its User setup. A printed version confirms that new PowerShell windows can find the editor.

✔️ I see a version number

Your editor is ready. PowerShell can launch Visual Studio Code from any project folder.

ⓧ Command not found

The Visual Studio Code User setup adds the editor command to the Windows path.

  • Visit the official Visual Studio Code download page in your Windows browser.
  • Download the User setup for Windows.
  • Run the downloaded installer.
  • Close every open PowerShell window after the installation finishes.

The final verification below checks the command again from a fresh terminal.

Installer or command still failing?

Restart Windows if a fresh PowerShell window still cannot find an installed tool. This refreshes environment changes made by the installers.

Help me troubleshoot my Windows Node.js or Visual Studio Code installation.

Prepare the browser test set

A local-area network lets each phone reach the game server running on your Windows PC. Every device needs the same trusted wireless network for that connection.

  • Press the Windows key on your PC to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter to confirm Microsoft Edge opens.
  • Open Google Chrome from the app list on your Android device.
  • Open Safari from the Home Screen or App Library on your iPhone or iPad.
  • Connect your Windows PC to a trusted Wi-Fi network.
  • Connect your Android device to the same Wi-Fi network.
  • Connect your iPhone or iPad to the same Wi-Fi network.

You should see the same Wi-Fi network name on all three devices. That shared network becomes the route into your Windows game server.

Missing one of the browsers?

Help me prepare my browser test devices for local multiplayer.

Create the project foundation

The package.json file records exactly what this project needs. PlayCanvas Engine supplies browser-based 3D rendering.

Socket.IO supplies the real-time connection used later. Pinning both versions keeps every installation consistent.

  • Switch back to the fresh PowerShell window from earlier.
  • Create the project on your Desktop and open it in Visual Studio Code by running these commands:
Set-Location -Path "$env:USERPROFILE\Desktop"
New-Item -Path ".\spirit-shrine-clash" -ItemType Directory
Set-Location -Path ".\spirit-shrine-clash"
New-Item -Path ".\public" -ItemType Directory
New-Item -Path ".\package.json" -ItemType File
code .

What do these commands create?

  • The first Set-Location command moves PowerShell to your Desktop.
  • The New-Item commands create the spirit-shrine-clash folder, its public subfolder, and an empty package.json file.
  • The final line opens the complete spirit-shrine-clash folder in Visual Studio Code.
  • Confirm Visual Studio Code lists package.json beside the public folder in the file list on the left.

Project folder not opening?

Confirm PowerShell is inside the Desktop copy of spirit-shrine-clash before launching the editor. A different location opens the wrong folder.

Help me find or open my Spirit Shrine Clash project folder.

  • Select package.json from the file list on the left.
  • Replace its empty contents with the following configuration:
{
  "name": "spirit-shrine-clash",
  "version": "1.0.0",
  "private": true,
  "description": "A local-network 3D multiplayer shrine arena.",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "playcanvas": "2.21.4",
    "socket.io": "4.8.3"
  }
}

What does this configuration do?

  • The project metadata names the game spirit-shrine-clash.
  • The start script records how npm launches the future Node.js server.
  • The dependency entries pin PlayCanvas Engine to 2.21.4.
  • The multiplayer dependency pins Socket.IO to 4.8.3.
  • Save package.json.
  • Confirm the editor no longer marks the file as unsaved.

Seeing JSON errors?

Compare every quote, comma, and brace with the reference below. One missing character can stop npm from reading the dependency list.

Help me fix my package.json syntax.

✔️ Awesome, I've got everything!

  • Keep package.json saved in the spirit-shrine-clash folder.

ⓧ I'd like to double check the full code

{
  "name": "spirit-shrine-clash",
  "version": "1.0.0",
  "private": true,
  "description": "A local-network 3D multiplayer shrine arena.",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "playcanvas": "2.21.4",
    "socket.io": "4.8.3"
  }
}
  • Switch back to the PowerShell window inside spirit-shrine-clash.
  • Install the dependencies recorded in package.json by running:
npm install

What does this command do?

npm reads the dependency list from package.json. It downloads the pinned packages into node_modules.

npm also generates package-lock.json to record the resolved dependency tree. That lock file keeps future installations consistent.

Keep the terminal open until npm returns to the PowerShell prompt. A completed installation does not report a failed dependency download.

Dependency installation failing?

  • Confirm PowerShell is inside the spirit-shrine-clash folder.
  • Confirm package.json matches the full-code reference above.
  • Confirm your Windows PC still has internet access for the package download.

Help me troubleshoot npm install for Spirit Shrine Clash.

  • List the completed project structure by running:
Get-ChildItem

What does this check show?

PowerShell lists the files and folders inside spirit-shrine-clash. This confirms that the dependency installation created persistent project artifacts.

You should see node_modules, package-lock.json, package.json, and public in the output.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh terminal.

Before you run the final checks, consider whether all three commands can answer from this fresh terminal.

  • Verify the complete Windows toolchain by running these commands:
node --version
npm --version
code --version

What should you see?

  • The Node.js check prints v24.20.0.
  • The npm check prints its installed version.
  • The Visual Studio Code check prints its installed version.

You made it through the setup work. Your runtime, package manager, and editor now answer from a clean PowerShell session.

One command still unavailable?

  • Restart Windows to reload changes made to the system path.
  • Return to the matching setup tab if the command still fails after the restart.

Help me complete the final Windows tool verification.

  • Place Visual Studio Code beside the fresh PowerShell window.

Your Windows game lab is ready. Next, you'll raise the moonlit shrine arena and move its first guardian.

Raise the Shrine Arena

Your Windows game lab is ready. A local Node.js server can now turn those installed packages into a game that runs in your browser.

This first arena gives you a visible result with PlayCanvas Engine. Its guardian still belongs to one browser, which creates the multiplayer gap you solve next.

In this step, get ready to:
  • Serve the project files through a local Node.js HTTP server.
  • Build the full-screen game page with its HUD plus directional controls.
  • Render the moonlit 3D shrine arena with one locally controlled guardian.
Serve the browser files

A browser needs an HTTP server to request the page, stylesheet, game module, and PlayCanvas module. The route map in server.js connects each browser URL to a file inside your project.

  • Return to Visual Studio Code with the spirit-shrine-clash project from the previous step.
  • Select the spirit-shrine-clash folder in the file tree.
  • Use the new-file control above the file tree to create server.js inside that folder.
  • Add the server imports plus file routes by pasting this first chunk into server.js:
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const PORT = 3000;

const files = {
  "/": {
    file: path.join(__dirname, "public", "index.html"),
    type: "text/html; charset=utf-8"
  },
  "/styles.css": {
    file: path.join(__dirname, "public", "styles.css"),
    type: "text/css; charset=utf-8"
  },
  "/game.js": {
    file: path.join(__dirname, "public", "game.js"),
    type: "text/javascript; charset=utf-8"
  },
  "/playcanvas.mjs": {
    file: path.join(__dirname, "node_modules", "playcanvas", "build", "playcanvas.mjs"),
    type: "text/javascript; charset=utf-8"
  }
};

What does this code do?

  • The three imports provide the web server, file reader, and Windows-safe path builder.
  • The files object maps each requested URL to its matching project file.
  • The /playcanvas.mjs route exposes the installed PlayCanvas module to the browser.
  • Save server.js.
  • Confirm that server.js appears beside package.json in the file tree.

Can't find server.js?

Check that the file sits directly inside spirit-shrine-clash. It should not be inside public.

Ask for help checking the file location.

The route map tells the server where each file lives. The request handler now reads the matching file and sends it with the correct content type.

  • Add the request handler plus local start message below the files object by pasting this chunk:
const server = http.createServer((request, response) => {
  const pathname = request.url.split("?")[0];
  const route = files[pathname];

  if (!route) {
    response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    response.end("Not found");
    return;
  }

  fs.readFile(route.file, (error, data) => {
    if (error) {
      console.error(error);
      response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      response.end("Server error");
      return;
    }

    response.writeHead(200, {
      "Content-Type": route.type,
      "Cache-Control": "no-store"
    });
    response.end(data);
  });
});

server.listen(PORT, () => {
  console.log(`Spirit Shrine Clash is running at http://localhost:${PORT}`);
});

How does the request handler work?

  • The handler removes any query string before looking up the requested path.
  • An unknown route returns a 404 response.
  • A file-reading problem returns a 500 response.
  • A successful request sends the file with its assigned content type.
  • Save server.js.
  • Switch back to the PowerShell terminal panel from the previous step.
  • Start the local game server by running:
npm start

What does this command do?

The start script in package.json runs server.js. The terminal stays occupied while the server listens for browser requests.

You should see Spirit Shrine Clash is running at http://localhost:3000 in PowerShell. Your local server is now taking requests.

Server did not start?

Confirm that PowerShell is inside the spirit-shrine-clash folder. Check the line number in any syntax error against the two server chunks.

Help me diagnose the server startup error.

✔️ Awesome, I've got everything!

Your server is running. Keep PowerShell active while you build the browser files.

ⓧ I'd like to double check the full code

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const PORT = 3000;

const files = {
  "/": {
    file: path.join(__dirname, "public", "index.html"),
    type: "text/html; charset=utf-8"
  },
  "/styles.css": {
    file: path.join(__dirname, "public", "styles.css"),
    type: "text/css; charset=utf-8"
  },
  "/game.js": {
    file: path.join(__dirname, "public", "game.js"),
    type: "text/javascript; charset=utf-8"
  },
  "/playcanvas.mjs": {
    file: path.join(__dirname, "node_modules", "playcanvas", "build", "playcanvas.mjs"),
    type: "text/javascript; charset=utf-8"
  }
};

const server = http.createServer((request, response) => {
  const pathname = request.url.split("?")[0];
  const route = files[pathname];

  if (!route) {
    response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    response.end("Not found");
    return;
  }

  fs.readFile(route.file, (error, data) => {
    if (error) {
      console.error(error);
      response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      response.end("Server error");
      return;
    }

    response.writeHead(200, {
      "Content-Type": route.type,
      "Cache-Control": "no-store"
    });
    response.end(data);
  });
});

server.listen(PORT, () => {
  console.log(`Spirit Shrine Clash is running at http://localhost:${PORT}`);
});
Build the game page and controls

The page needs two layers. A full-screen canvas holds the 3D scene while an HTML interface keeps the score, mission, and controls readable above it.

  • Select the existing public folder in the Visual Studio Code file tree.
  • Use the new-file control to create index.html inside public.
  • Build the game page by pasting this code into public/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Spirit Shrine Clash</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <canvas id="game"></canvas>

  <section id="hud" aria-live="polite">
    <h1>Spirit Shrine Clash</h1>
    <p id="score">Azure 0 | 0 Crimson</p>
    <p id="status">Connecting to the ritual...</p>
    <p id="mission">Claim the Spirit Flame and carry it into the rival shrine.</p>
    <button id="restart" type="button" hidden>Start another duel</button>
  </section>

  <section id="controls" aria-label="Movement controls">
    <button class="control up" type="button" data-x="0" data-z="-1" aria-label="Move up">▲</button>
    <button class="control left" type="button" data-x="-1" data-z="0" aria-label="Move left">◀</button>
    <button class="control down" type="button" data-x="0" data-z="1" aria-label="Move down">▼</button>
    <button class="control right" type="button" data-x="1" data-z="0" aria-label="Move right">▶</button>
  </section>

  <script src="/socket.io/socket.io.js"></script>
  <script type="module" src="/game.js"></script>
</body>
</html>

How is the page organized?

  • The game canvas becomes the PlayCanvas drawing surface.
  • The hud section groups the score, connection status, objective, and hidden restart button.
  • Each control button stores its movement direction in data-x plus data-z.
  • The final module script loads game.js after the page structure exists.
  • Save public/index.html.
  • Press the Windows key to open Windows search.
  • Type Microsoft Edge.
  • Press Enter to open Microsoft Edge.
  • Enter http://localhost:3000 in the address bar.

You should see the Spirit Shrine Clash heading, mission text, score, and four unstyled direction buttons. That plain interface proves the server can deliver index.html.

Page not loading?

Confirm that PowerShell still shows the running server. Check that index.html sits inside public.

Help me fix the local page request.

✔️ Awesome, I've got everything!

Your page structure is ready. The stylesheet can now turn it into a game interface.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Spirit Shrine Clash</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <canvas id="game"></canvas>

  <section id="hud" aria-live="polite">
    <h1>Spirit Shrine Clash</h1>
    <p id="score">Azure 0 | 0 Crimson</p>
    <p id="status">Connecting to the ritual...</p>
    <p id="mission">Claim the Spirit Flame and carry it into the rival shrine.</p>
    <button id="restart" type="button" hidden>Start another duel</button>
  </section>

  <section id="controls" aria-label="Movement controls">
    <button class="control up" type="button" data-x="0" data-z="-1" aria-label="Move up">▲</button>
    <button class="control left" type="button" data-x="-1" data-z="0" aria-label="Move left">◀</button>
    <button class="control down" type="button" data-x="0" data-z="1" aria-label="Move down">▼</button>
    <button class="control right" type="button" data-x="1" data-z="0" aria-label="Move right">▶</button>
  </section>

  <script src="/socket.io/socket.io.js"></script>
  <script type="module" src="/game.js"></script>
</body>
</html>

The stylesheet keeps the 3D canvas full-screen while placing the interface above it. Responsive sizing preserves large touch targets on Android plus iPhone and iPad screens.

  • Use the new-file control in the public folder to create styles.css.
  • Add the page foundation plus full-screen canvas by pasting this first chunk into public/styles.css:
:root {
  color-scheme: dark;
  font-family: Inter, Segoe UI, sans-serif;
}

* {
  box-sizing: border-box;
}

html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
  background: #080b16;
}

#game {
  position: fixed;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

What does the foundation control?

The page fills the browser window without scrollbars. The canvas occupies that same space behind every interface element.

  • Save public/styles.css.
  • Refresh Microsoft Edge.

You should see the page fill the entire browser with a dark background. The interface no longer sits on a white document.

Still seeing a white page?

Check that the file is named styles.css inside public. Confirm that the stylesheet link in index.html uses /styles.css.

Help me connect the stylesheet.

  • Add the floating HUD container plus title styling below the existing CSS by pasting:
#hud {
  position: fixed;
  top: 14px;
  left: 50%;
  z-index: 2;
  width: min(92vw, 560px);
  padding: 12px 16px;
  border: 1px solid rgba(255, 226, 151, 0.5);
  border-radius: 14px;
  background: rgba(8, 11, 22, 0.78);
  box-shadow: 0 10px 35px rgba(0, 0, 0, 0.4);
  text-align: center;
  transform: translateX(-50%);
  pointer-events: none;
}

#hud h1,
#hud p {
  margin: 4px 0;
}

#hud h1 {
  color: #ffe297;
  font-size: clamp(20px, 4vw, 30px);
  letter-spacing: 0.05em;
}

Why does the HUD float?

Fixed positioning anchors the HUD above the 3D canvas. Its translucent background keeps the arena visible while preserving readable mission text.

  • Save public/styles.css.
  • Refresh Microsoft Edge.

You should see a centered translucent panel near the top of the page. Its title appears in gold above the score.

HUD not centered?

Check that both left: 50% and transform: translateX(-50%) appear inside the hud rule.

Help me fix the HUD alignment.

  • Style the score, status, mission, and restart control by adding:
#score {
  color: #ffffff;
  font-size: clamp(18px, 3vw, 24px);
  font-weight: 800;
}

#status {
  color: #b9e8ff;
}

#mission {
  color: #d9d9e8;
  font-size: 14px;
}

#restart {
  margin-top: 8px;
  padding: 10px 16px;
  border: 0;
  border-radius: 999px;
  background: #ffe297;
  color: #17121f;
  font-weight: 800;
  pointer-events: auto;
}

How does the HUD show priority?

The white score carries the strongest visual weight. The blue status and muted mission text separate live feedback from the objective.

  • Save public/styles.css.
  • Refresh Microsoft Edge.

You should see a bold white score, a blue status line, and smaller mission text inside the HUD. The restart button remains hidden until a later game state uses it.

HUD text colors unchanged?

Confirm that score, status, and mission match the IDs in index.html.

Help me match the HUD selectors.

  • Position the directional control grid plus its buttons by adding:
#controls {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 3;
  display: grid;
  grid-template-columns: repeat(3, 64px);
  grid-template-rows: repeat(2, 64px);
  gap: 8px;
  touch-action: none;
  user-select: none;
}

.control {
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-radius: 18px;
  background: rgba(20, 28, 48, 0.82);
  color: white;
  font-size: 25px;
  font-weight: 900;
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
  touch-action: none;
}

Why use large control buttons?

The fixed grid keeps the controls within thumb reach. Large cells give touch input a wider target on phones and tablets.

  • Save public/styles.css.
  • Refresh Microsoft Edge.

You should see four rounded direction buttons grouped in the lower-right corner. Their positions are refined in the next chunk.

Controls still look like plain buttons?

Check that every direction button has the control class. Confirm that the CSS selector starts with a full stop.

Help me style the control buttons.

  • Arrange the direction buttons into a directional pad by adding:
.control:active {
  background: rgba(255, 226, 151, 0.88);
  color: #17121f;
}

.up {
  grid-column: 2;
  grid-row: 1;
}

.left {
  grid-column: 1;
  grid-row: 2;
}

.down {
  grid-column: 2;
  grid-row: 2;
}

.right {
  grid-column: 3;
  grid-row: 2;
}

How does the direction pad work?

Each directional class occupies a specific grid cell. The active state changes color while a pointer presses a button.

  • Save public/styles.css.
  • Refresh Microsoft Edge.
  • Press one direction button with the pointer.

You should see an up button above a row of left, down, and right buttons. The pressed button turns gold.

Direction pad layout incorrect?

Confirm that each button has its matching up, left, down, or right class.

Help me repair the control grid.

  • Add desktop plus narrow-screen adjustments by pasting the final stylesheet chunk:
@media (pointer: fine) {
  #controls {
    opacity: 0.58;
  }
}

@media (max-width: 620px) {
  #hud {
    top: 8px;
    padding: 8px 12px;
  }

  #controls {
    right: 12px;
    bottom: 12px;
    grid-template-columns: repeat(3, 58px);
    grid-template-rows: repeat(2, 58px);
  }
}

How does the layout adapt?

Precise pointer devices receive softer controls so they do not dominate the desktop view. Narrow screens use tighter HUD spacing plus smaller direction cells.

  • Save public/styles.css.
  • Refresh Microsoft Edge.
  • Resize the browser window until it becomes narrower than the normal desktop layout.

You should see the HUD move closer to the top edge. The direction pad also becomes smaller and shifts closer to the lower-right corner.

Mobile layout not changing?

Check that the narrow-screen rule contains max-width: 620px. Confirm that both closing braces appear at the end of the media rule.

Help me debug the responsive CSS.

✔️ Awesome, I've got everything!

Your game interface now fills the browser and adapts to smaller screens.

ⓧ I'd like to double check the full code

:root {
  color-scheme: dark;
  font-family: Inter, Segoe UI, sans-serif;
}

* {
  box-sizing: border-box;
}

html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
  background: #080b16;
}

#game {
  position: fixed;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

#hud {
  position: fixed;
  top: 14px;
  left: 50%;
  z-index: 2;
  width: min(92vw, 560px);
  padding: 12px 16px;
  border: 1px solid rgba(255, 226, 151, 0.5);
  border-radius: 14px;
  background: rgba(8, 11, 22, 0.78);
  box-shadow: 0 10px 35px rgba(0, 0, 0, 0.4);
  text-align: center;
  transform: translateX(-50%);
  pointer-events: none;
}

#hud h1,
#hud p {
  margin: 4px 0;
}

#hud h1 {
  color: #ffe297;
  font-size: clamp(20px, 4vw, 30px);
  letter-spacing: 0.05em;
}

#score {
  color: #ffffff;
  font-size: clamp(18px, 3vw, 24px);
  font-weight: 800;
}

#status {
  color: #b9e8ff;
}

#mission {
  color: #d9d9e8;
  font-size: 14px;
}

#restart {
  margin-top: 8px;
  padding: 10px 16px;
  border: 0;
  border-radius: 999px;
  background: #ffe297;
  color: #17121f;
  font-weight: 800;
  pointer-events: auto;
}

#controls {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 3;
  display: grid;
  grid-template-columns: repeat(3, 64px);
  grid-template-rows: repeat(2, 64px);
  gap: 8px;
  touch-action: none;
  user-select: none;
}

.control {
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-radius: 18px;
  background: rgba(20, 28, 48, 0.82);
  color: white;
  font-size: 25px;
  font-weight: 900;
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
  touch-action: none;
}

.control:active {
  background: rgba(255, 226, 151, 0.88);
  color: #17121f;
}

.up {
  grid-column: 2;
  grid-row: 1;
}

.left {
  grid-column: 1;
  grid-row: 2;
}

.down {
  grid-column: 2;
  grid-row: 2;
}

.right {
  grid-column: 3;
  grid-row: 2;
}

@media (pointer: fine) {
  #controls {
    opacity: 0.58;
  }
}

@media (max-width: 620px) {
  #hud {
    top: 8px;
    padding: 8px 12px;
  }

  #controls {
    right: 12px;
    bottom: 12px;
    grid-template-columns: repeat(3, 58px);
    grid-template-rows: repeat(2, 58px);
  }
}
Raise the 3D shrine scene

A PlayCanvas scene is a tree of entities. The camera views that tree while materials, lights, and render components give each entity its visible form.

  • Use the new-file control in the public folder to create game.js.
  • Initialize PlayCanvas plus the arena material palette by pasting this first chunk into public/game.js:
import * as pc from "/playcanvas.mjs";

const canvas = document.getElementById("game");
const app = new pc.Application(canvas);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
window.addEventListener("resize", () => app.resizeCanvas());
app.start();

function makeMaterial(red, green, blue, glow = 0) {
  const material = new pc.StandardMaterial();
  material.diffuse.set(red, green, blue);
  material.emissive.set(red * glow, green * glow, blue * glow);
  material.gloss = 0.35;
  material.update();
  return material;
}

const materials = {
  stone: makeMaterial(0.18, 0.2, 0.28),
  path: makeMaterial(0.32, 0.3, 0.42),
  wood: makeMaterial(0.32, 0.12, 0.09),
  roof: makeMaterial(0.08, 0.06, 0.13),
  azure: makeMaterial(0.08, 0.55, 0.95, 0.18),
  crimson: makeMaterial(0.95, 0.18, 0.28, 0.16),
  flame: makeMaterial(1, 0.72, 0.16, 0.8),
  gold: makeMaterial(0.95, 0.72, 0.2, 0.22)
};

What does the engine bootstrap do?

  • The module import exposes the PlayCanvas API through pc.
  • The application uses the existing canvas as its drawing surface.
  • The resize listener keeps the 3D view aligned with the browser window.
  • The material helper creates the stone, clan, flame, and gold finishes used across the scene.
  • Save public/game.js.
  • Refresh Microsoft Edge.

You should see the HUD remain above a running black canvas. This confirms that the PlayCanvas module loaded and started.

Canvas did not start?

Check that the import path is /playcanvas.mjs. Confirm that PowerShell still runs the local server.

Help me diagnose the PlayCanvas startup problem.

  • Add the interface references, world-primitive helper, camera, and moon light below the material palette by pasting:
const scoreText = document.getElementById("score");
const statusText = document.getElementById("status");
const restartButton = document.getElementById("restart");

function worldPrimitive(name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  app.root.addChild(entity);
  return entity;
}

const camera = new pc.Entity("Camera");
camera.addComponent("camera", {
  clearColor: new pc.Color(0.025, 0.035, 0.09)
});
camera.setPosition(0, 12, 13);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);

const light = new pc.Entity("Moon Light");
light.addComponent("light");
light.setEulerAngles(48, 35, 0);
app.root.addChild(light);

How does the scene become visible?

The helper turns a name, primitive type, size, position, and material into a rendered entity. The elevated camera looks toward the arena center while the angled light reveals its shapes.

  • Save public/game.js.
  • Refresh Microsoft Edge.

You should see the canvas change from black to a deep moonlit blue. The camera now controls the view even though the arena geometry is still empty.

Canvas still black?

Check that the camera is added to app.root. Confirm that the camera component uses the camera type.

Help me fix the scene camera.

  • Build the arena floor, central seal, boundary stones, and child-primitive helper by adding:
function childPrimitive(parent, name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setLocalPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  parent.addChild(entity);
  return entity;
}

worldPrimitive("Ground", "box", [16, 0.4, 10], [0, -0.25, 0], materials.stone);
worldPrimitive("Moon Path", "box", [15, 0.06, 2.6], [0, 0, 0], materials.path);
worldPrimitive("Central Seal", "torus", [3.2, 0.35, 3.2], [0, 0.18, 0], materials.gold);

for (let x = -6; x <= 6; x += 2) {
  worldPrimitive(`North Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, -4.65], materials.stone);
  worldPrimitive(`South Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, 4.65], materials.stone);
}

How is the arena assembled?

Large box primitives create the ground plus moon path. A torus marks the objective zone while paired stones repeat along the arena boundaries.

  • Save public/game.js.
  • Refresh Microsoft Edge.

You should see a wide stone platform, a lighter central path, a gold ring, and boundary stones under the moonlit camera.

Arena floor missing?

Check that every worldPrimitive() call appears after the helper definition. Confirm that the camera still looks at the center.

Help me find the missing arena geometry.

  • Create the mirrored Azure plus Crimson shrines by adding:
function buildShrine(team, centerX, material) {
  worldPrimitive(`${team} Shrine Base`, "cylinder", [2.8, 0.35, 2.8], [centerX, 0.3, 0], material);
  worldPrimitive(`${team} Shrine Ring`, "torus", [3.6, 0.4, 3.6], [centerX, 0.5, 0], material);
  worldPrimitive(`${team} Pillar North`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, -1.05], materials.wood);
  worldPrimitive(`${team} Pillar South`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, 1.05], materials.wood);
  worldPrimitive(`${team} Shrine Roof`, "box", [1.6, 0.25, 3], [centerX, 2.65, 0], materials.roof);
}

buildShrine("Azure", -7, materials.azure);
buildShrine("Crimson", 7, materials.crimson);

Why use one shrine factory?

The same geometry builds both clan shrines. Their horizontal position plus material creates the mirrored Azure and Crimson sides.

  • Save public/game.js.
  • Refresh Microsoft Edge.

You should see an Azure shrine on the left plus a Crimson shrine on the right. Each shrine has a base, ring, two pillars, and a roof.

Only one shrine visible?

Check that both buildShrine() calls appear below the function. Their center positions should use opposite values.

Help me restore the mirrored shrines.

  • Add the Spirit Flame plus one local Azure guardian by pasting:
const flameEntity = worldPrimitive("Spirit Flame", "sphere", [0.75, 0.75, 0.75], [0, 1.15, 0], materials.flame);

function createGuardian(player) {
  const guardian = new pc.Entity(`Guardian ${player.id}`);
  const teamMaterial = player.team === "azure" ? materials.azure : materials.crimson;

  childPrimitive(guardian, "Body", "capsule", [0.85, 1.25, 0.85], [0, 0, 0], teamMaterial);
  childPrimitive(guardian, "Mask", "sphere", [0.7, 0.55, 0.5], [0, 0.65, -0.2], materials.gold);
  childPrimitive(guardian, "Crown", "cone", [0.75, 0.9, 0.75], [0, 1.3, 0], teamMaterial);

  guardian.setPosition(player.x, 0.85, player.z);
  app.root.addChild(guardian);
  return guardian;
}

const localPlayer = {
  id: "local",
  team: "azure",
  x: -5.5,
  z: 0
};
const localGuardian = createGuardian(localPlayer);
statusText.textContent = "You are Azure. Claim the Spirit Flame.";

How is the guardian constructed?

The guardian is a parent entity with capsule, sphere, and cone children. Moving the parent keeps its body, mask, and crown together.

The local player object gives the factory a temporary team plus spawn point. Server-owned player data replaces this temporary source in the next step.

  • Save public/game.js.
  • Refresh Microsoft Edge.

You should see a glowing Spirit Flame at the center plus an Azure guardian near the left shrine. The status line now assigns you to Azure.

Guardian or flame missing?

Check that flameEntity and localGuardian are created after their helper functions. Confirm that each referenced material exists in materials.

Help me find the missing scene entity.

Movement starts as direction intent. The update loop reads that intent every frame, moves the guardian within the arena bounds, and rotates the flame.

  • Add the shared input calculation plus animation loop by pasting:
const heldKeys = new Set();
const heldPointers = new Map();

function currentInput() {
  let x = 0;
  let z = 0;

  if (heldKeys.has("KeyA") || heldKeys.has("ArrowLeft")) x -= 1;
  if (heldKeys.has("KeyD") || heldKeys.has("ArrowRight")) x += 1;
  if (heldKeys.has("KeyW") || heldKeys.has("ArrowUp")) z -= 1;
  if (heldKeys.has("KeyS") || heldKeys.has("ArrowDown")) z += 1;

  for (const direction of heldPointers.values()) {
    x += direction.x;
    z += direction.z;
  }

  return { x, z };
}

app.on("update", (dt) => {
  const input = currentInput();
  const current = localGuardian.getPosition();
  localGuardian.setPosition(
    Math.max(-7.4, Math.min(7.4, current.x + input.x * 4.5 * dt)),
    0.85,
    Math.max(-4.4, Math.min(4.4, current.z + input.z * 4.5 * dt))
  );
  flameEntity.rotate(0, 90 * dt, 0);
});

How does local movement work?

  • The two collections hold active keyboard keys plus pressed pointer directions.
  • The currentInput() function converts those active inputs into horizontal movement.
  • The update loop multiplies movement by elapsed frame time for consistent speed.
  • The horizontal limits keep the guardian inside the visible arena.
  • Save public/game.js.
  • Refresh Microsoft Edge.

You should see the Spirit Flame rotating at the center. That motion proves the game update loop is running continuously.

Flame not rotating?

Check that the app.on block closes correctly. Confirm that flameEntity is defined before the update loop.

Help me repair the update loop.

  • Connect WASD plus arrow-key presses to the input collection by adding:
const movementKeys = new Set([
  "KeyW", "KeyA", "KeyS", "KeyD",
  "ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight"
]);

window.addEventListener("keydown", (event) => {
  if (movementKeys.has(event.code)) {
    heldKeys.add(event.code);
    event.preventDefault();
  }
});

window.addEventListener("keyup", (event) => {
  heldKeys.delete(event.code);
});

How do held keys become movement?

A key press enters the heldKeys collection. Releasing that key removes it, so movement continues only while the key stays down.

  • Save public/game.js.
  • Refresh Microsoft Edge.
  • Hold the W key.
  • Test the remaining WASD plus arrow keys.

You should see the Azure guardian move around the arena while the HUD remains fixed. The guardian stops at each arena boundary.

Keyboard movement not responding?

Click the game page once so Microsoft Edge has keyboard focus. Check that each key name in movementKeys matches the corresponding check in currentInput().

Help me debug the keyboard controls.

  • Connect the four on-screen direction buttons to the pointer input collection by adding the final chunk:
for (const button of document.querySelectorAll(".control")) {
  button.addEventListener("pointerdown", (event) => {
    event.preventDefault();
    heldPointers.set(event.pointerId, {
      x: Number(button.dataset.x),
      z: Number(button.dataset.z)
    });
  });
}

function releasePointer(event) {
  heldPointers.delete(event.pointerId);
}

window.addEventListener("pointerup", releasePointer);
window.addEventListener("pointercancel", releasePointer);

How do the touch-ready controls work?

Each pressed button contributes the direction stored in its data attributes. Releasing or cancelling the pointer removes that direction so the guardian stops.

  • Save public/game.js.
  • Refresh Microsoft Edge.
  • Hold one on-screen direction button with the pointer.
  • Test the other three direction buttons.

You should see the same Azure guardian respond to every on-screen direction. Your desktop browser now demonstrates the touch-ready control path.

Direction buttons not moving the guardian?

Check that every button uses the control class. Confirm that each button has numeric data-x plus data-z values.

Help me debug the pointer controls.

✔️ Awesome, I've got everything!

Your local arena has its complete scene plus keyboard and pointer controls.

ⓧ I'd like to double check the full code

import * as pc from "/playcanvas.mjs";

const canvas = document.getElementById("game");
const app = new pc.Application(canvas);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
window.addEventListener("resize", () => app.resizeCanvas());
app.start();

function makeMaterial(red, green, blue, glow = 0) {
  const material = new pc.StandardMaterial();
  material.diffuse.set(red, green, blue);
  material.emissive.set(red * glow, green * glow, blue * glow);
  material.gloss = 0.35;
  material.update();
  return material;
}

const materials = {
  stone: makeMaterial(0.18, 0.2, 0.28),
  path: makeMaterial(0.32, 0.3, 0.42),
  wood: makeMaterial(0.32, 0.12, 0.09),
  roof: makeMaterial(0.08, 0.06, 0.13),
  azure: makeMaterial(0.08, 0.55, 0.95, 0.18),
  crimson: makeMaterial(0.95, 0.18, 0.28, 0.16),
  flame: makeMaterial(1, 0.72, 0.16, 0.8),
  gold: makeMaterial(0.95, 0.72, 0.2, 0.22)
};

const scoreText = document.getElementById("score");
const statusText = document.getElementById("status");
const restartButton = document.getElementById("restart");

function worldPrimitive(name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  app.root.addChild(entity);
  return entity;
}

const camera = new pc.Entity("Camera");
camera.addComponent("camera", {
  clearColor: new pc.Color(0.025, 0.035, 0.09)
});
camera.setPosition(0, 12, 13);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);

const light = new pc.Entity("Moon Light");
light.addComponent("light");
light.setEulerAngles(48, 35, 0);
app.root.addChild(light);

function childPrimitive(parent, name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setLocalPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  parent.addChild(entity);
  return entity;
}

worldPrimitive("Ground", "box", [16, 0.4, 10], [0, -0.25, 0], materials.stone);
worldPrimitive("Moon Path", "box", [15, 0.06, 2.6], [0, 0, 0], materials.path);
worldPrimitive("Central Seal", "torus", [3.2, 0.35, 3.2], [0, 0.18, 0], materials.gold);

for (let x = -6; x <= 6; x += 2) {
  worldPrimitive(`North Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, -4.65], materials.stone);
  worldPrimitive(`South Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, 4.65], materials.stone);
}

function buildShrine(team, centerX, material) {
  worldPrimitive(`${team} Shrine Base`, "cylinder", [2.8, 0.35, 2.8], [centerX, 0.3, 0], material);
  worldPrimitive(`${team} Shrine Ring`, "torus", [3.6, 0.4, 3.6], [centerX, 0.5, 0], material);
  worldPrimitive(`${team} Pillar North`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, -1.05], materials.wood);
  worldPrimitive(`${team} Pillar South`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, 1.05], materials.wood);
  worldPrimitive(`${team} Shrine Roof`, "box", [1.6, 0.25, 3], [centerX, 2.65, 0], materials.roof);
}

buildShrine("Azure", -7, materials.azure);
buildShrine("Crimson", 7, materials.crimson);

const flameEntity = worldPrimitive("Spirit Flame", "sphere", [0.75, 0.75, 0.75], [0, 1.15, 0], materials.flame);

function createGuardian(player) {
  const guardian = new pc.Entity(`Guardian ${player.id}`);
  const teamMaterial = player.team === "azure" ? materials.azure : materials.crimson;

  childPrimitive(guardian, "Body", "capsule", [0.85, 1.25, 0.85], [0, 0, 0], teamMaterial);
  childPrimitive(guardian, "Mask", "sphere", [0.7, 0.55, 0.5], [0, 0.65, -0.2], materials.gold);
  childPrimitive(guardian, "Crown", "cone", [0.75, 0.9, 0.75], [0, 1.3, 0], teamMaterial);

  guardian.setPosition(player.x, 0.85, player.z);
  app.root.addChild(guardian);
  return guardian;
}

const localPlayer = {
  id: "local",
  team: "azure",
  x: -5.5,
  z: 0
};
const localGuardian = createGuardian(localPlayer);
statusText.textContent = "You are Azure. Claim the Spirit Flame.";

const heldKeys = new Set();
const heldPointers = new Map();

function currentInput() {
  let x = 0;
  let z = 0;

  if (heldKeys.has("KeyA") || heldKeys.has("ArrowLeft")) x -= 1;
  if (heldKeys.has("KeyD") || heldKeys.has("ArrowRight")) x += 1;
  if (heldKeys.has("KeyW") || heldKeys.has("ArrowUp")) z -= 1;
  if (heldKeys.has("KeyS") || heldKeys.has("ArrowDown")) z += 1;

  for (const direction of heldPointers.values()) {
    x += direction.x;
    z += direction.z;
  }

  return { x, z };
}

app.on("update", (dt) => {
  const input = currentInput();
  const current = localGuardian.getPosition();
  localGuardian.setPosition(
    Math.max(-7.4, Math.min(7.4, current.x + input.x * 4.5 * dt)),
    0.85,
    Math.max(-4.4, Math.min(4.4, current.z + input.z * 4.5 * dt))
  );
  flameEntity.rotate(0, 90 * dt, 0);
});

const movementKeys = new Set([
  "KeyW", "KeyA", "KeyS", "KeyD",
  "ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight"
]);

window.addEventListener("keydown", (event) => {
  if (movementKeys.has(event.code)) {
    heldKeys.add(event.code);
    event.preventDefault();
  }
});

window.addEventListener("keyup", (event) => {
  heldKeys.delete(event.code);
});

for (const button of document.querySelectorAll(".control")) {
  button.addEventListener("pointerdown", (event) => {
    event.preventDefault();
    heldPointers.set(event.pointerId, {
      x: Number(button.dataset.x),
      z: Number(button.dataset.z)
    });
  });
}

function releasePointer(event) {
  heldPointers.delete(event.pointerId);
}

window.addEventListener("pointerup", releasePointer);
window.addEventListener("pointercancel", releasePointer);

Before you test the finished arena, which input methods do you expect to move the same guardian?

  • Return to Microsoft Edge from earlier.
  • Refresh http://localhost:3000.
  • Move the guardian with WASD.
  • Move the guardian with the arrow keys.
  • Move the guardian with all four on-screen direction buttons.

You should see a moonlit mirrored arena with Azure and Crimson shrines, a rotating central flame, and one Azure guardian. Every keyboard plus pointer control moves that same local guardian.

Arena loads but movement fails?

Confirm that the browser page has focus. Check that the input listeners appear after heldKeys plus heldPointers are created.

Help me trace the complete local movement path.

That is the first playable slice complete. Next, you replace its browser-owned guardian with synchronized state that multiple clients can share.

Make the Server Own the Guardians

Your PlayCanvas Engine arena now has a guardian that moves inside one browser. Each browser still controls its own isolated copy, so separate tabs cannot share the same match.

This step moves guardian positions into server-authoritative state. Socket.IO carries player input to the server. Every browser renders the snapshots that come back.

In this step, get ready to:
  • Assign each connected guardian to a balanced team on the server.
  • Render server-owned guardians from authoritative state snapshots.
  • Test two synchronized Edge tabs before exposing the local-address limitation on a phone.
Give the server authoritative state

The Node.js process already serves the arena files. It now needs to remember each player between requests, validate movement input, and publish one shared version of the match.

  • In server.js, replace the imports and constants at the top with this multiplayer version:
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { Server } = require("socket.io");

const PORT = 3000;
const TICK_MS = 50;
const MOVE_SPEED = 4.5;
const ARENA_X = 7.4;
const ARENA_Z = 4.4;

What does this code do?

  • The Server import adds Socket.IO to the existing HTTP server.
  • TICK_MS sets a fixed update interval of 50 milliseconds.
  • MOVE_SPEED controls how far a guardian moves each second.
  • ARENA_X and ARENA_Z define the movement boundaries.
  • Find the closing }); for http.createServer().
  • Add the shared match state and player helpers directly below it by copying this code:
const io = new Server(server);
const players = {};
const scores = { azure: 0, crimson: 0 };
const flame = { x: 0, y: 1.15, z: 0, carrierId: null };
let winner = null;

function clamp(value, minimum, maximum) {
  return Math.max(minimum, Math.min(maximum, value));
}

function teamCount(team) {
  return Object.values(players).filter((player) => player.team === team).length;
}

function spawnPlayer(id) {
  const team = teamCount("azure") <= teamCount("crimson") ? "azure" : "crimson";
  const laneIndex = teamCount(team);
  const laneOffsets = [0, -1.8, 1.8, -3.6, 3.6];

  return {
    id,
    team,
    x: team === "azure" ? -5.5 : 5.5,
    z: laneOffsets[laneIndex % laneOffsets.length],
    input: { x: 0, z: 0 }
  };
}

How does the server place players?

  • players stores the current guardian record for every connected browser.
  • teamCount() counts the guardians already assigned to one clan.
  • spawnPlayer() gives the next connection to the smaller team.
  • clamp() keeps future movement inside the arena boundaries.
  • Save server.js.
  • Switch back to the PowerShell terminal from earlier.
  • Press Ctrl+C to stop the current server.
  • Start the updated server by running:
npm start

What does this check prove?

PowerShell should print the Spirit Shrine Clash local URL again. This confirms that the HTTP server and Socket.IO can start inside the same process.

The server starts successfully with shared player storage ready. The next addition defines what each browser receives.

Server stopped after the edit?

Check that the Socket.IO import sits below the existing path import. Confirm that new Server(server) appears after the HTTP server is created.

Ask for help with the startup failure.

  • In server.js, add publicState() directly below spawnPlayer() by copying this code:
function publicState() {
  return {
    players: Object.values(players).map(({ input, ...player }) => player),
    scores: { ...scores },
    flame: { ...flame },
    winner
  };
}

Why hide player input?

publicState() creates the snapshot sent to browsers. It removes each internal input object because clients only need the resulting position and team.

  • Add the Socket.IO connection handler directly below publicState() by copying this code:
io.on("connection", (socket) => {
  players[socket.id] = spawnPlayer(socket.id);
  socket.emit("identity", {
    id: socket.id,
    team: players[socket.id].team
  });

  socket.on("input", (payload) => {
    const player = players[socket.id];
    if (!player || winner) return;

    let x = Number(payload?.x);
    let z = Number(payload?.z);
    x = Number.isFinite(x) ? clamp(x, -1, 1) : 0;
    z = Number.isFinite(z) ? clamp(z, -1, 1) : 0;

    const magnitude = Math.hypot(x, z);
    if (magnitude > 1) {
      x /= magnitude;
      z /= magnitude;
    }

    player.input = { x, z };
  });

  socket.on("disconnect", () => {
    delete players[socket.id];
  });
});

How does input stay trustworthy?

  • identity tells one browser its socket ID and assigned team.
  • input carries direction intent instead of a client-selected position.
  • Number.isFinite() rejects values that cannot become valid movement numbers.
  • Math.hypot() normalizes diagonal input so it cannot move faster.
  • disconnect removes a guardian when its browser leaves.
  • Save server.js.
  • Switch back to the PowerShell terminal.
  • Press Ctrl+C to stop the running process.
  • Start the server again by running:
npm start

What does this restart prove?

The local URL should print without a startup failure. The server can now accept connections, assign identities, and store validated direction input.

  • Add the fixed update loop below the Socket.IO connection handler by copying this code:
setInterval(() => {
  if (!winner) {
    const seconds = TICK_MS / 1000;

    for (const player of Object.values(players)) {
      player.x = clamp(player.x + player.input.x * MOVE_SPEED * seconds, -ARENA_X, ARENA_X);
      player.z = clamp(player.z + player.input.z * MOVE_SPEED * seconds, -ARENA_Z, ARENA_Z);
    }
  }

  io.emit("state", publicState());
}, TICK_MS);

What does the update loop own?

  • The loop converts the 50 millisecond interval into seconds before calculating movement.
  • Each guardian position changes from its validated server-side input.
  • clamp() prevents movement beyond the arena edges.
  • io.emit() sends the same authoritative snapshot to every connected browser.
  • Save server.js.
  • Switch back to the PowerShell terminal.
  • Press Ctrl+C to stop the current process.
  • Start the authoritative update loop by running:
npm start

What should PowerShell show?

PowerShell should print the local game URL and remain running. The quiet terminal means the fixed update loop is active without throwing an exception.

Update loop causing a startup failure?

Confirm that the loop appears after the Socket.IO connection handler. Check the closing braces around the for loop and the setInterval() callback.

Ask for help checking the update loop.

Use these tabs to confirm the cumulative server.js file before changing the browser client.

✔️ Awesome, I've got everything!

Your server now owns team assignment, movement input, positions, arena boundaries, and state snapshots. Keep PowerShell running for the client changes.

ⓧ I'd like to double check the full code

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { Server } = require("socket.io");

const PORT = 3000;
const TICK_MS = 50;
const MOVE_SPEED = 4.5;
const ARENA_X = 7.4;
const ARENA_Z = 4.4;

const files = {
  "/": {
    file: path.join(__dirname, "public", "index.html"),
    type: "text/html; charset=utf-8"
  },
  "/styles.css": {
    file: path.join(__dirname, "public", "styles.css"),
    type: "text/css; charset=utf-8"
  },
  "/game.js": {
    file: path.join(__dirname, "public", "game.js"),
    type: "text/javascript; charset=utf-8"
  },
  "/playcanvas.mjs": {
    file: path.join(__dirname, "node_modules", "playcanvas", "build", "playcanvas.mjs"),
    type: "text/javascript; charset=utf-8"
  }
};

const server = http.createServer((request, response) => {
  const pathname = request.url.split("?")[0];
  const route = files[pathname];

  if (!route) {
    response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    response.end("Not found");
    return;
  }

  fs.readFile(route.file, (error, data) => {
    if (error) {
      console.error(error);
      response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      response.end("Server error");
      return;
    }

    response.writeHead(200, {
      "Content-Type": route.type,
      "Cache-Control": "no-store"
    });
    response.end(data);
  });
});

const io = new Server(server);
const players = {};
const scores = { azure: 0, crimson: 0 };
const flame = { x: 0, y: 1.15, z: 0, carrierId: null };
let winner = null;

function clamp(value, minimum, maximum) {
  return Math.max(minimum, Math.min(maximum, value));
}

function teamCount(team) {
  return Object.values(players).filter((player) => player.team === team).length;
}

function spawnPlayer(id) {
  const team = teamCount("azure") <= teamCount("crimson") ? "azure" : "crimson";
  const laneIndex = teamCount(team);
  const laneOffsets = [0, -1.8, 1.8, -3.6, 3.6];

  return {
    id,
    team,
    x: team === "azure" ? -5.5 : 5.5,
    z: laneOffsets[laneIndex % laneOffsets.length],
    input: { x: 0, z: 0 }
  };
}

function publicState() {
  return {
    players: Object.values(players).map(({ input, ...player }) => player),
    scores: { ...scores },
    flame: { ...flame },
    winner
  };
}

io.on("connection", (socket) => {
  players[socket.id] = spawnPlayer(socket.id);
  socket.emit("identity", {
    id: socket.id,
    team: players[socket.id].team
  });

  socket.on("input", (payload) => {
    const player = players[socket.id];
    if (!player || winner) return;

    let x = Number(payload?.x);
    let z = Number(payload?.z);
    x = Number.isFinite(x) ? clamp(x, -1, 1) : 0;
    z = Number.isFinite(z) ? clamp(z, -1, 1) : 0;

    const magnitude = Math.hypot(x, z);
    if (magnitude > 1) {
      x /= magnitude;
      z /= magnitude;
    }

    player.input = { x, z };
  });

  socket.on("disconnect", () => {
    delete players[socket.id];
  });
});

setInterval(() => {
  if (!winner) {
    const seconds = TICK_MS / 1000;

    for (const player of Object.values(players)) {
      player.x = clamp(player.x + player.input.x * MOVE_SPEED * seconds, -ARENA_X, ARENA_X);
      player.z = clamp(player.z + player.input.z * MOVE_SPEED * seconds, -ARENA_Z, ARENA_Z);
    }
  }

  io.emit("state", publicState());
}, TICK_MS);

server.listen(PORT, () => {
  console.log(`Spirit Shrine Clash is running at http://localhost:${PORT}`);
  console.log("Use this PC's IPv4 address instead of localhost on other devices.");
});

How to use this reference

Compare this file with your own from top to bottom. Keep the static routes unchanged while checking the Socket.IO state, connection handler, and update loop.

Render authoritative guardians

The server now knows where every guardian belongs. The browser must stop treating its test guardian as the source of truth and start rendering the shared snapshots.

  • Switch back to public/game.js in VS Code.
  • Find the flameEntity declaration.
  • Add the multiplayer collections directly below it by copying this code:
const playerEntities = new Map();
const targetPositions = new Map();
let flameTarget = { x: 0, y: 1.15, z: 0 };
let myId = null;
let myTeam = null;

What does the client remember?

  • playerEntities maps socket IDs to visible PlayCanvas guardians.
  • targetPositions stores the latest server position for each guardian.
  • myId identifies the guardian controlled by this browser.
  • myTeam stores this browser's Azure or Crimson assignment.
  • Keep the existing createGuardian() factory below the new collections.
  • Add the team label helper directly below createGuardian() by copying this code:
function teamLabel(team) {
  return team === "azure" ? "Azure" : "Crimson";
}

Why keep a team label helper?

The server uses lowercase team values for predictable state. teamLabel() turns those values into readable HUD text.

  • Add the connection lifecycle handlers directly below teamLabel() by copying this code:
socket.on("connect", () => {
  statusText.textContent = "Connected. Waiting for your clan assignment...";
});

socket.on("connect_error", () => {
  statusText.textContent = "Connection failed. Check the server address and Windows Firewall.";
});

socket.on("disconnect", () => {
  statusText.textContent = "Disconnected. The client will try to reconnect.";
});

socket.on("identity", (identity) => {
  myId = identity.id;
  myTeam = identity.team;
  statusText.textContent = `You are ${teamLabel(myTeam)}. Claim the Spirit Flame.`;
});

What do these events reveal?

  • connect confirms that the browser reached Socket.IO.
  • connect_error replaces silence with a useful connection status.
  • disconnect tells the player that the client is trying to reconnect.
  • identity stores the server-issued player ID and team.
  • Save public/game.js.
  • Refresh the existing Edge tab.

The HUD should identify you as Azure or Crimson. That message proves the browser has received its server-issued identity.

Still waiting for a clan assignment?

Confirm that PowerShell is still running the updated server. Check that /socket.io/socket.io.js remains above /game.js in public/index.html.

Ask for help tracing the missing identity event.

  • Add the authoritative state handler directly below the identity handler by copying this code:
socket.on("state", (state) => {
  const activeIds = new Set();

  for (const player of state.players) {
    activeIds.add(player.id);
    if (!playerEntities.has(player.id)) {
      playerEntities.set(player.id, createGuardian(player));
    }
    targetPositions.set(player.id, player);
  }

  for (const [id, entity] of playerEntities) {
    if (!activeIds.has(id)) {
      entity.destroy();
      playerEntities.delete(id);
      targetPositions.delete(id);
    }
  }

  flameTarget = state.flame;
  scoreText.textContent = `Azure ${state.scores.azure} | ${state.scores.crimson} Crimson`;
});

How do snapshots become guardians?

  • activeIds records every player found in the newest snapshot.
  • createGuardian() creates a PlayCanvas entity the first time an ID appears.
  • targetPositions receives the newest position without moving the entity immediately.
  • entity.destroy() removes guardians that disappear from server state.
  • Remove the earlier line that creates one local test guardian.
  • Replace the local guardian update callback with this snapshot interpolation callback:
app.on("update", (dt) => {
  const blend = Math.min(1, dt * 10);

  for (const [id, entity] of playerEntities) {
    const target = targetPositions.get(id);
    if (!target) continue;

    const current = entity.getPosition();
    entity.setPosition(
      current.x + (target.x - current.x) * blend,
      0.85,
      current.z + (target.z - current.z) * blend
    );
  }

  const flamePosition = flameEntity.getPosition();
  flameEntity.setPosition(
    flamePosition.x + (flameTarget.x - flamePosition.x) * blend,
    flamePosition.y + (flameTarget.y - flamePosition.y) * blend,
    flamePosition.z + (flameTarget.z - flamePosition.z) * blend
  );
  flameEntity.rotate(0, 90 * dt, 0);
});

Why interpolate snapshots?

The server sends positions every 50 milliseconds. The PlayCanvas update callback blends toward each target every rendered frame, which keeps movement smooth between snapshots.

  • Save public/game.js.
  • Refresh the Edge tab.

You should see one server-created guardian at its clan spawn. The locally positioned test guardian is gone.

Guardian missing after the refresh?

Check that createGuardian(player) receives each player from state.players. Confirm that the old local update callback was fully replaced.

Ask for help tracing the guardian snapshot.

  • Find the existing pointercancel listener near the bottom of public/game.js.
  • Add the input emission interval directly below it by copying this code:
setInterval(() => {
  if (socket.connected) {
    socket.emit("input", currentInput());
  }
}, 50);

What crosses the network?

The browser sends the normalized result from currentInput() every 50 milliseconds. The server receives direction intent and calculates the real position.

  • Save public/game.js.
  • Refresh the Edge tab.
  • Hold a WASD key to test movement.

Your guardian should move smoothly and stop at the arena boundaries. That motion now comes back from the server snapshots.

Guardian appears but does not move?

Confirm that the interval calls currentInput() with parentheses. Check that the server still stores the validated result in player.input.

Ask for help following one movement update.

Use these tabs to compare the complete multiplayer client before opening a second browser tab.

✔️ Awesome, I've got everything!

Your browser now creates, updates, and removes guardians from server snapshots. Keyboard and pointer controls send direction input without choosing positions locally.

ⓧ I'd like to double check the full code

import * as pc from "/playcanvas.mjs";

const canvas = document.getElementById("game");
const scoreText = document.getElementById("score");
const statusText = document.getElementById("status");
const restartButton = document.getElementById("restart");
const socket = window.io();

const app = new pc.Application(canvas);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
window.addEventListener("resize", () => app.resizeCanvas());
app.start();

function makeMaterial(red, green, blue, glow = 0) {
  const material = new pc.StandardMaterial();
  material.diffuse.set(red, green, blue);
  material.emissive.set(red * glow, green * glow, blue * glow);
  material.gloss = 0.35;
  material.update();
  return material;
}

const materials = {
  stone: makeMaterial(0.18, 0.2, 0.28),
  path: makeMaterial(0.32, 0.3, 0.42),
  wood: makeMaterial(0.32, 0.12, 0.09),
  roof: makeMaterial(0.08, 0.06, 0.13),
  azure: makeMaterial(0.08, 0.55, 0.95, 0.18),
  crimson: makeMaterial(0.95, 0.18, 0.28, 0.16),
  flame: makeMaterial(1, 0.72, 0.16, 0.8),
  gold: makeMaterial(0.95, 0.72, 0.2, 0.22)
};

function worldPrimitive(name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  app.root.addChild(entity);
  return entity;
}

function childPrimitive(parent, name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setLocalPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  parent.addChild(entity);
  return entity;
}

const camera = new pc.Entity("Camera");
camera.addComponent("camera", {
  clearColor: new pc.Color(0.025, 0.035, 0.09)
});
camera.setPosition(0, 12, 13);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);

const light = new pc.Entity("Moon Light");
light.addComponent("light");
light.setEulerAngles(48, 35, 0);
app.root.addChild(light);

worldPrimitive("Ground", "box", [16, 0.4, 10], [0, -0.25, 0], materials.stone);
worldPrimitive("Moon Path", "box", [15, 0.06, 2.6], [0, 0, 0], materials.path);
worldPrimitive("Central Seal", "torus", [3.2, 0.35, 3.2], [0, 0.18, 0], materials.gold);

for (let x = -6; x <= 6; x += 2) {
  worldPrimitive(`North Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, -4.65], materials.stone);
  worldPrimitive(`South Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, 4.65], materials.stone);
}

function buildShrine(team, centerX, material) {
  worldPrimitive(`${team} Shrine Base`, "cylinder", [2.8, 0.35, 2.8], [centerX, 0.3, 0], material);
  worldPrimitive(`${team} Shrine Ring`, "torus", [3.6, 0.4, 3.6], [centerX, 0.5, 0], material);
  worldPrimitive(`${team} Pillar North`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, -1.05], materials.wood);
  worldPrimitive(`${team} Pillar South`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, 1.05], materials.wood);
  worldPrimitive(`${team} Shrine Roof`, "box", [1.6, 0.25, 3], [centerX, 2.65, 0], materials.roof);
}

buildShrine("Azure", -7, materials.azure);
buildShrine("Crimson", 7, materials.crimson);

const flameEntity = worldPrimitive("Spirit Flame", "sphere", [0.75, 0.75, 0.75], [0, 1.15, 0], materials.flame);
const playerEntities = new Map();
const targetPositions = new Map();
let flameTarget = { x: 0, y: 1.15, z: 0 };
let myId = null;
let myTeam = null;

function createGuardian(player) {
  const guardian = new pc.Entity(`Guardian ${player.id}`);
  const teamMaterial = player.team === "azure" ? materials.azure : materials.crimson;

  childPrimitive(guardian, "Body", "capsule", [0.85, 1.25, 0.85], [0, 0, 0], teamMaterial);
  childPrimitive(guardian, "Mask", "sphere", [0.7, 0.55, 0.5], [0, 0.65, -0.2], materials.gold);
  childPrimitive(guardian, "Crown", "cone", [0.75, 0.9, 0.75], [0, 1.3, 0], teamMaterial);

  guardian.setPosition(player.x, 0.85, player.z);
  app.root.addChild(guardian);
  return guardian;
}

function teamLabel(team) {
  return team === "azure" ? "Azure" : "Crimson";
}

socket.on("connect", () => {
  statusText.textContent = "Connected. Waiting for your clan assignment...";
});

socket.on("connect_error", () => {
  statusText.textContent = "Connection failed. Check the server address and Windows Firewall.";
});

socket.on("disconnect", () => {
  statusText.textContent = "Disconnected. The client will try to reconnect.";
});

socket.on("identity", (identity) => {
  myId = identity.id;
  myTeam = identity.team;
  statusText.textContent = `You are ${teamLabel(myTeam)}. Claim the Spirit Flame.`;
});

socket.on("state", (state) => {
  const activeIds = new Set();

  for (const player of state.players) {
    activeIds.add(player.id);
    if (!playerEntities.has(player.id)) {
      playerEntities.set(player.id, createGuardian(player));
    }
    targetPositions.set(player.id, player);
  }

  for (const [id, entity] of playerEntities) {
    if (!activeIds.has(id)) {
      entity.destroy();
      playerEntities.delete(id);
      targetPositions.delete(id);
    }
  }

  flameTarget = state.flame;
  scoreText.textContent = `Azure ${state.scores.azure} | ${state.scores.crimson} Crimson`;
});

app.on("update", (dt) => {
  const blend = Math.min(1, dt * 10);

  for (const [id, entity] of playerEntities) {
    const target = targetPositions.get(id);
    if (!target) continue;

    const current = entity.getPosition();
    entity.setPosition(
      current.x + (target.x - current.x) * blend,
      0.85,
      current.z + (target.z - current.z) * blend
    );
  }

  const flamePosition = flameEntity.getPosition();
  flameEntity.setPosition(
    flamePosition.x + (flameTarget.x - flamePosition.x) * blend,
    flamePosition.y + (flameTarget.y - flamePosition.y) * blend,
    flamePosition.z + (flameTarget.z - flamePosition.z) * blend
  );
  flameEntity.rotate(0, 90 * dt, 0);
});

const heldKeys = new Set();
const heldPointers = new Map();
const movementKeys = new Set([
  "KeyW", "KeyA", "KeyS", "KeyD",
  "ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight"
]);

window.addEventListener("keydown", (event) => {
  if (movementKeys.has(event.code)) {
    heldKeys.add(event.code);
    event.preventDefault();
  }
});

window.addEventListener("keyup", (event) => {
  heldKeys.delete(event.code);
});

for (const button of document.querySelectorAll(".control")) {
  button.addEventListener("pointerdown", (event) => {
    event.preventDefault();
    heldPointers.set(event.pointerId, {
      x: Number(button.dataset.x),
      z: Number(button.dataset.z)
    });
  });
}

function releasePointer(event) {
  heldPointers.delete(event.pointerId);
}

window.addEventListener("pointerup", releasePointer);
window.addEventListener("pointercancel", releasePointer);

function currentInput() {
  let x = 0;
  let z = 0;

  if (heldKeys.has("KeyA") || heldKeys.has("ArrowLeft")) x -= 1;
  if (heldKeys.has("KeyD") || heldKeys.has("ArrowRight")) x += 1;
  if (heldKeys.has("KeyW") || heldKeys.has("ArrowUp")) z -= 1;
  if (heldKeys.has("KeyS") || heldKeys.has("ArrowDown")) z += 1;

  for (const direction of heldPointers.values()) {
    x += direction.x;
    z += direction.z;
  }

  const magnitude = Math.hypot(x, z);
  if (magnitude > 1) {
    x /= magnitude;
    z /= magnitude;
  }

  return { x, z };
}

setInterval(() => {
  if (socket.connected) {
    socket.emit("input", currentInput());
  }
}, 50);

How to use this reference

Compare the scene setup first. Continue through the Socket.IO handlers, interpolation callback, and input interval to find any missing or duplicated local-player code.

Test two clients and hit the localhost limit

One browser proves the connection works. Two browsers reveal whether the server truly owns one shared match.

Before you test, how many guardians do you expect each tab to show after a second browser connects?

  • Switch back to the existing Edge game tab.
  • Refresh the page.
  • Open a second Edge tab.
  • Enter http://localhost:3000 in the second tab's address bar.
  • Hold a movement key in the first tab.
  • Switch to the second tab to compare the guardian positions.

Both tabs should show two distinct guardians. Moving either guardian should update its matching position in both tabs.

That is the shared-world breakthrough. Your two browser clients now render movement calculated by one authoritative server.

Tabs show different guardian positions?

Refresh both tabs after confirming that one PowerShell process is running. Check that movement changes player.input on the server instead of changing a guardian position directly in the browser.

Ask for help comparing the two client states.

A phone provides the final test for this step. Before you try it, do you expect localhost to point to the Windows PC or to the phone itself?

  • On Android, enter http://localhost:3000 in Google Chrome.
  • On iPhone or iPad, enter http://localhost:3000 in Safari.

The game should fail to load on the phone or tablet. This failure is intentional because localhost refers to the device displaying the page.

What did the failure prove?

Each device has its own local address. The phone looked for a game server running on the phone, while the real process remained on the Windows PC.

The shared game works inside the PC. The next step gives the mobile browsers an address that reaches that PC across Wi-Fi.

Your guardians now share one server-owned arena across desktop tabs. Next, you will bring Android and iPhone or iPad into that same match over your local network.

Connect the Arena Over LAN

Two Microsoft Edge tabs now render the same server-owned guardians through Socket.IO. The failed phone test exposed the boundary around localhost.

A local-area network lets each device reach the Windows PC through its Wi-Fi address. This step makes the Node.js server reachable at that address while keeping access limited to your trusted private network.

In this step, get ready to:
  • Expose the Node.js server to trusted LAN devices.
  • Find the Windows PC's active Wi-Fi address.
  • Join one shared match from Windows, Android, and iPhone/iPad.
Let Node.js listen on the LAN

A listener bound to loopback can answer requests only from the Windows PC. Omitting the host argument lets Node.js listen on the available network interfaces.

  • Switch back to Visual Studio Code.
  • Select server.js in the Explorer sidebar.
  • Scroll to the server.listen call at the bottom of the file.
  • Replace the listener block so no loopback host argument remains:
server.listen(PORT, () => {
  console.log(`Spirit Shrine Clash is running at http://localhost:${PORT}`);
  console.log("Use this PC's IPv4 address instead of localhost on other devices.");
});

What does this code do?

  • The server.listen(PORT) call starts the server without restricting it to the Windows loopback interface.
  • The callback confirms that the game server is ready after it starts.
  • Windows can still use http://localhost:3000 because the server remains available on the same PC.
  • Save server.js.
  • Switch back to the PowerShell window running the game server.
  • Stop the current server by pressing Ctrl+C.
  • Restart the game server by running:
npm start

What does this command do?

The npm start command runs the start script from package.json. The restarted process uses the listener change you just saved.

You should see the Spirit Shrine Clash server message again. Your existing Edge page should reconnect to the arena.

Server does not restart?

Check that the earlier server process stopped before running the command. A process that still owns port 3000 prevents another server from using it.

Confirm that the final listener block starts with server.listen(PORT, () => {.

Help me diagnose why the Spirit Shrine Clash server does not restart after changing server.listen.

✔️ Awesome, I've got everything!

Your server is running with the LAN-ready listener. Keep the PowerShell process running while the devices connect.

ⓧ I'd like to double check the full code

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { Server } = require("socket.io");

const PORT = 3000;
const TICK_MS = 50;
const MOVE_SPEED = 4.5;
const ARENA_X = 7.4;
const ARENA_Z = 4.4;

const files = {
  "/": {
    file: path.join(__dirname, "public", "index.html"),
    type: "text/html; charset=utf-8"
  },
  "/styles.css": {
    file: path.join(__dirname, "public", "styles.css"),
    type: "text/css; charset=utf-8"
  },
  "/game.js": {
    file: path.join(__dirname, "public", "game.js"),
    type: "text/javascript; charset=utf-8"
  },
  "/playcanvas.mjs": {
    file: path.join(__dirname, "node_modules", "playcanvas", "build", "playcanvas.mjs"),
    type: "text/javascript; charset=utf-8"
  }
};

const server = http.createServer((request, response) => {
  const pathname = request.url.split("?")[0];
  const route = files[pathname];

  if (!route) {
    response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    response.end("Not found");
    return;
  }

  fs.readFile(route.file, (error, data) => {
    if (error) {
      console.error(error);
      response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      response.end("Server error");
      return;
    }

    response.writeHead(200, {
      "Content-Type": route.type,
      "Cache-Control": "no-store"
    });
    response.end(data);
  });
});

const io = new Server(server);
const players = {};
const scores = { azure: 0, crimson: 0 };
const flame = { x: 0, y: 1.15, z: 0, carrierId: null };
let winner = null;

function clamp(value, minimum, maximum) {
  return Math.max(minimum, Math.min(maximum, value));
}

function teamCount(team) {
  return Object.values(players).filter((player) => player.team === team).length;
}

function spawnPlayer(id) {
  const team = teamCount("azure") <= teamCount("crimson") ? "azure" : "crimson";
  const laneIndex = teamCount(team);
  const laneOffsets = [0, -1.8, 1.8, -3.6, 3.6];

  return {
    id,
    team,
    x: team === "azure" ? -5.5 : 5.5,
    z: laneOffsets[laneIndex % laneOffsets.length],
    input: { x: 0, z: 0 }
  };
}

function publicState() {
  return {
    players: Object.values(players).map(({ input, ...player }) => player),
    scores: { ...scores },
    flame: { ...flame },
    winner
  };
}

io.on("connection", (socket) => {
  players[socket.id] = spawnPlayer(socket.id);
  socket.emit("identity", {
    id: socket.id,
    team: players[socket.id].team
  });

  socket.on("input", (payload) => {
    const player = players[socket.id];
    if (!player || winner) return;

    let x = Number(payload?.x);
    let z = Number(payload?.z);
    x = Number.isFinite(x) ? clamp(x, -1, 1) : 0;
    z = Number.isFinite(z) ? clamp(z, -1, 1) : 0;

    const magnitude = Math.hypot(x, z);
    if (magnitude > 1) {
      x /= magnitude;
      z /= magnitude;
    }

    player.input = { x, z };
  });

  socket.on("disconnect", () => {
    delete players[socket.id];
  });
});

setInterval(() => {
  if (!winner) {
    const seconds = TICK_MS / 1000;

    for (const player of Object.values(players)) {
      player.x = clamp(player.x + player.input.x * MOVE_SPEED * seconds, -ARENA_X, ARENA_X);
      player.z = clamp(player.z + player.input.z * MOVE_SPEED * seconds, -ARENA_Z, ARENA_Z);
    }
  }

  io.emit("state", publicState());
}, TICK_MS);

server.listen(PORT, () => {
  console.log(`Spirit Shrine Clash is running at http://localhost:${PORT}`);
  console.log("Use this PC's IPv4 address instead of localhost on other devices.");
});

What should you compare?

The HTTP routes still serve the same browser files. The Socket.IO handlers still own guardian movement.

The important LAN change is the final server.listen(PORT) call with no host argument.

Find your Windows IPv4 address

Your Windows PC has an address on the shared Wi-Fi network. Mobile devices use this address to reach the game server instead of pointing localhost back at themselves.

  • Press the Windows key to open search.
  • Type Windows PowerShell into the search box.
  • Press Enter to start a second PowerShell window.
  • Display the PC's network addresses by running:
ipconfig

What does this command show?

The ipconfig command lists the network adapters on your Windows PC. Each active adapter includes its current network addresses.

The Wi-Fi adapter's IPv4 Address is the value your other devices need.

  • Locate the active Wi-Fi adapter in the output.
  • Find its IPv4 Address value.
  • Record the active adapter's IPv4 address here: your Windows IPv4 address.

Seeing several IPv4 addresses?

Use the address under the adapter connected to the same Wi-Fi network as your phones. Ignore disconnected adapters plus entries that belong to virtual networking software.

Help me identify the active Wi-Fi IPv4 address in my ipconfig output.

Join the match from every device

A LAN game URL combines the Windows IPv4 address with port 3000. Every device must use the same trusted Wi-Fi network for that route to work.

  • Confirm that the Windows PC is connected to your trusted Wi-Fi network.
  • Confirm that the Android device is connected to the same Wi-Fi network.
  • Confirm that the iPhone or iPad is connected to the same Wi-Fi network.
  • Leave the Edge tab connected at http://localhost:3000.
  • Launch Google Chrome on Android.
  • Enter http://your Windows IPv4 address:3000 in the address bar.
  • Launch Safari on the iPhone or iPad.
  • Enter http://your Windows IPv4 address:3000 in the address bar.

The first mobile load reveals whether Windows accepts incoming requests from your private network. Choose the path that matches what you see.

✔️ Both mobile browsers joined

Great, the moonlit arena now loads on Android plus iPhone or iPad. Your existing firewall settings already permit the local connection.

ⓧ A mobile browser cannot connect

Changing a Windows Firewall permission can feel risky. This rule permits Node.js only on your trusted private network.

  • Press the Windows key to open search.
  • Type Windows Security into the search box.
  • Press Enter to launch the app.
  • Select Firewall & network protection.
  • Select Allow an app through firewall.
  • Select Change settings.

The app list now lets you scope incoming access by network profile. Keep the permission limited to networks you trust.

  • Locate the Node.js entry in the allowed-app list.
  • Select the checkbox in the Private column for Node.js.
  • Leave every other network-profile checkbox for Node.js cleared.
  • Select OK to save the permission.

Node.js missing from the list?

Use the app-add control on the allowed-app screen to select the Node.js executable installed on your PC. Keep its access limited to the Private profile.

Help me add Node.js to the Windows Firewall allowed-app list for Private networks only.

  • Return to Google Chrome on Android.
  • Reload the game URL.
  • Return to Safari on the iPhone or iPad.
  • Reload the game URL.

Both mobile browsers should now load the same moonlit arena as Edge.

Before you move anyone, do you expect keyboard input plus touch input to update one shared match or three separate copies?

  • Hold W briefly in Edge on Windows.
  • Press the right direction button on Android.
  • Press the down direction button on the iPhone or iPad.

You will see the Windows guardian move on all three screens. Each touch-controlled guardian also moves on all three screens.

You have now turned a desktop prototype into a cross-device game.

Your guardians now share one authoritative arena across Windows plus two mobile platforms. Next up, you will give them a contested Spirit Flame plus a complete match objective.

Complete the Spirit Flame Mission

Your guardians already move together across the local network. Every connected browser now sees the same server-owned positions.

Synchronized movement proves the transport works. A complete match also needs one contested objective with shared scoring. It needs a victory state with a synchronized restart.

In this step, get ready to:
  • Make the server own the Spirit Flame.
  • Connect the client HUD to authoritative match state.
  • Complete a first-to-three cross-device match.
Add the server-owned objective

A server-authoritative state gives every browser one source of truth for the flame. The server decides who carries it. The server also decides when a team scores.

  • Press Ctrl+C in the PowerShell terminal from earlier to stop the running server.
  • Switch back to server.js in Visual Studio Code.
  • Replace the constants at the top of server.js with this complete constants block:
const PORT = 3000;
const TICK_MS = 50;
const MOVE_SPEED = 4.5;
const ARENA_X = 7.4;
const ARENA_Z = 4.4;
const PICKUP_RADIUS = 1.1;
const SHRINE_LINE = 6.6;
const WIN_SCORE = 3;

What do these constants control?

  • The pickup radius decides how close a guardian must be to claim the flame.
  • The shrine line marks the scoring edge inside the opposing shrine.
  • The win score ends the match when one team reaches three points.
  • Find the existing io plus players declarations.
  • Replace those declarations with the full shared-state block below:
const io = new Server(server);
const players = {};
const scores = { azure: 0, crimson: 0 };
const flame = { x: 0, y: 1.15, z: 0, carrierId: null };
let winner = null;

What does this state represent?

  • The scores object stores the authoritative team totals.
  • The flame object stores its shared position plus its current carrier.
  • The winner value stays empty until a team reaches the win score.
  • Save server.js.
  • Check the new state declarations by running this command in PowerShell:
npm start

What should I see?

You should see the local game address plus the LAN reminder from the server. This confirms that the new constants and state declarations have valid syntax.

  • Press Ctrl+C to stop the server before the next edit.
  • Add this helper directly below spawnPlayer():
function resetFlame() {
  flame.x = 0;
  flame.y = 1.15;
  flame.z = 0;
  flame.carrierId = null;
}

What does this helper do?

The helper returns the flame to the central seal. It also clears the carrier so another guardian can claim it.

  • Add the round-reset helper directly below resetFlame():
function resetRound() {
  const teamIndexes = { azure: 0, crimson: 0 };
  const laneOffsets = [0, -1.8, 1.8, -3.6, 3.6];

  for (const player of Object.values(players)) {
    const laneIndex = teamIndexes[player.team];
    teamIndexes[player.team] += 1;
    player.x = player.team === "azure" ? -5.5 : 5.5;
    player.z = laneOffsets[laneIndex % laneOffsets.length];
    player.input = { x: 0, z: 0 };
  }

  resetFlame();
}

How does a round reset work?

Each guardian returns to a team spawn lane. Current movement stops before the flame returns to the center.

  • Save server.js.
  • Confirm that both reset helpers compile by running:
npm start

What should I see now?

You should see the same two startup messages without a syntax error. The reset helpers are now ready for scoring events.

  • Press Ctrl+C to stop the server.
  • Add the score helper directly below resetRound():
function addScore(team) {
  scores[team] += 1;
  if (scores[team] >= WIN_SCORE) {
    winner = team;
  }
  resetRound();
}

How does scoring end a match?

A successful seal increments one team total. Reaching three points records the winner before the next round reset.

  • Add the public-state helper directly below addScore():
function publicState() {
  return {
    players: Object.values(players).map(({ input, ...player }) => player),
    scores: { ...scores },
    flame: { ...flame },
    winner
  };
}

Why create a public snapshot?

The snapshot contains everything each browser needs to render the match. Private movement input stays on the server.

  • Save server.js.
  • Confirm that the scoring helpers compile by running:
npm start

What should I see this time?

You should see the server start normally. This proves the shared snapshot can be created without changing the existing connection flow.

  • Press Ctrl+C to stop the server.
  • Replace the existing disconnect section with this combined restart plus cleanup section:
  socket.on("restart", () => {
    scores.azure = 0;
    scores.crimson = 0;
    winner = null;
    resetRound();
  });

  socket.on("disconnect", () => {
    if (flame.carrierId === socket.id) {
      resetFlame();
    }
    delete players[socket.id];
  });

What do these events protect?

A restart request clears the match state for every client. A departing carrier drops the flame back at the center before the player is removed.

  • Find the player movement loop inside setInterval().
  • Add the pickup check directly after that movement loop:
    if (!flame.carrierId) {
      for (const player of Object.values(players)) {
        if (Math.hypot(player.x - flame.x, player.z - flame.z) <= PICKUP_RADIUS) {
          flame.carrierId = player.id;
          break;
        }
      }
    }

How is one carrier enforced?

The pickup search only runs while the flame has no carrier. The first guardian inside the pickup radius claims it before the loop stops.

  • Save server.js.
  • Check the restart plus pickup logic by running:
npm start

What should I see after this check?

You should see the server start without an error. Connected guardians can now claim the flame inside the authoritative update loop.

  • Press Ctrl+C to stop the server.
  • Add the carrier plus scoring section directly below the pickup check:
    let scoringTeam = null;
    if (flame.carrierId) {
      const carrier = players[flame.carrierId];
      if (!carrier) {
        resetFlame();
      } else {
        flame.x = carrier.x;
        flame.y = 1.45;
        flame.z = carrier.z;

        if (carrier.team === "azure" && carrier.x >= SHRINE_LINE) {
          scoringTeam = "azure";
        }
        if (carrier.team === "crimson" && carrier.x <= -SHRINE_LINE) {
          scoringTeam = "crimson";
        }
      }
    }

    if (scoringTeam) {
      addScore(scoringTeam);
    }

How does the flame follow the carrier?

The flame copies the carrier's authoritative position during every update. Crossing the rival shrine line identifies the team that earns the point.

  • Find the state broadcast at the bottom of setInterval().
  • Replace that broadcast with the public-state call below:
  io.emit("state", publicState());

What changes in each broadcast?

Every snapshot now carries guardians, team scores, flame state, and the winner. All browsers receive the same result from one server update.

  • Save server.js.
  • Start the completed objective server by running:
npm start

What should I see?

You should see the local address plus the LAN reminder. Keep this PowerShell process running while you update the browser client.

Server not starting?

Check that every helper sits outside the surrounding functions. A misplaced brace can place one helper inside another.

Compare the bottom of the update loop with the full-file reference below. Make sure the public-state broadcast remains inside setInterval().

Help me debug the Spirit Flame server changes.

The reference below shows the exact completed server.js file for this step.

✔️ Awesome, I've got everything!

Your server now owns the complete objective match state.

ⓧ I'd like to double check the full code

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { Server } = require("socket.io");

const PORT = 3000;
const TICK_MS = 50;
const MOVE_SPEED = 4.5;
const ARENA_X = 7.4;
const ARENA_Z = 4.4;
const PICKUP_RADIUS = 1.1;
const SHRINE_LINE = 6.6;
const WIN_SCORE = 3;

const files = {
  "/": {
    file: path.join(__dirname, "public", "index.html"),
    type: "text/html; charset=utf-8"
  },
  "/styles.css": {
    file: path.join(__dirname, "public", "styles.css"),
    type: "text/css; charset=utf-8"
  },
  "/game.js": {
    file: path.join(__dirname, "public", "game.js"),
    type: "text/javascript; charset=utf-8"
  },
  "/playcanvas.mjs": {
    file: path.join(__dirname, "node_modules", "playcanvas", "build", "playcanvas.mjs"),
    type: "text/javascript; charset=utf-8"
  }
};

const server = http.createServer((request, response) => {
  const pathname = request.url.split("?")[0];
  const route = files[pathname];

  if (!route) {
    response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    response.end("Not found");
    return;
  }

  fs.readFile(route.file, (error, data) => {
    if (error) {
      console.error(error);
      response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
      response.end("Server error");
      return;
    }

    response.writeHead(200, {
      "Content-Type": route.type,
      "Cache-Control": "no-store"
    });
    response.end(data);
  });
});

const io = new Server(server);
const players = {};
const scores = { azure: 0, crimson: 0 };
const flame = { x: 0, y: 1.15, z: 0, carrierId: null };
let winner = null;

function clamp(value, minimum, maximum) {
  return Math.max(minimum, Math.min(maximum, value));
}

function teamCount(team) {
  return Object.values(players).filter((player) => player.team === team).length;
}

function spawnPlayer(id) {
  const team = teamCount("azure") <= teamCount("crimson") ? "azure" : "crimson";
  const laneIndex = teamCount(team);
  const laneOffsets = [0, -1.8, 1.8, -3.6, 3.6];

  return {
    id,
    team,
    x: team === "azure" ? -5.5 : 5.5,
    z: laneOffsets[laneIndex % laneOffsets.length],
    input: { x: 0, z: 0 }
  };
}

function resetFlame() {
  flame.x = 0;
  flame.y = 1.15;
  flame.z = 0;
  flame.carrierId = null;
}

function resetRound() {
  const teamIndexes = { azure: 0, crimson: 0 };
  const laneOffsets = [0, -1.8, 1.8, -3.6, 3.6];

  for (const player of Object.values(players)) {
    const laneIndex = teamIndexes[player.team];
    teamIndexes[player.team] += 1;
    player.x = player.team === "azure" ? -5.5 : 5.5;
    player.z = laneOffsets[laneIndex % laneOffsets.length];
    player.input = { x: 0, z: 0 };
  }

  resetFlame();
}

function addScore(team) {
  scores[team] += 1;
  if (scores[team] >= WIN_SCORE) {
    winner = team;
  }
  resetRound();
}

function publicState() {
  return {
    players: Object.values(players).map(({ input, ...player }) => player),
    scores: { ...scores },
    flame: { ...flame },
    winner
  };
}

io.on("connection", (socket) => {
  players[socket.id] = spawnPlayer(socket.id);
  socket.emit("identity", {
    id: socket.id,
    team: players[socket.id].team
  });

  socket.on("input", (payload) => {
    const player = players[socket.id];
    if (!player || winner) return;

    let x = Number(payload?.x);
    let z = Number(payload?.z);
    x = Number.isFinite(x) ? clamp(x, -1, 1) : 0;
    z = Number.isFinite(z) ? clamp(z, -1, 1) : 0;

    const magnitude = Math.hypot(x, z);
    if (magnitude > 1) {
      x /= magnitude;
      z /= magnitude;
    }

    player.input = { x, z };
  });

  socket.on("restart", () => {
    scores.azure = 0;
    scores.crimson = 0;
    winner = null;
    resetRound();
  });

  socket.on("disconnect", () => {
    if (flame.carrierId === socket.id) {
      resetFlame();
    }
    delete players[socket.id];
  });
});

setInterval(() => {
  if (!winner) {
    const seconds = TICK_MS / 1000;

    for (const player of Object.values(players)) {
      player.x = clamp(player.x + player.input.x * MOVE_SPEED * seconds, -ARENA_X, ARENA_X);
      player.z = clamp(player.z + player.input.z * MOVE_SPEED * seconds, -ARENA_Z, ARENA_Z);
    }

    if (!flame.carrierId) {
      for (const player of Object.values(players)) {
        if (Math.hypot(player.x - flame.x, player.z - flame.z) <= PICKUP_RADIUS) {
          flame.carrierId = player.id;
          break;
        }
      }
    }

    let scoringTeam = null;
    if (flame.carrierId) {
      const carrier = players[flame.carrierId];
      if (!carrier) {
        resetFlame();
      } else {
        flame.x = carrier.x;
        flame.y = 1.45;
        flame.z = carrier.z;

        if (carrier.team === "azure" && carrier.x >= SHRINE_LINE) {
          scoringTeam = "azure";
        }
        if (carrier.team === "crimson" && carrier.x <= -SHRINE_LINE) {
          scoringTeam = "crimson";
        }
      }
    }

    if (scoringTeam) {
      addScore(scoringTeam);
    }
  }

  io.emit("state", publicState());
}, TICK_MS);

server.listen(PORT, () => {
  console.log(`Spirit Shrine Clash is running at http://localhost:${PORT}`);
  console.log("Use this PC's IPv4 address instead of localhost on other devices.");
});

How to use this reference

Compare each section with your saved file. Keep server.listen(PORT) unchanged so your phones can still connect through the Windows IPv4 address.

Connect the flame to the browser HUD

The PlayCanvas Engine scene already interpolates authoritative entities. The client now needs to connect the shared flame plus match results to the existing HUD.

  • Switch back to public/game.js in Visual Studio Code.
  • Replace the DOM reference block at the top of the file with this complete version:
const canvas = document.getElementById("game");
const scoreText = document.getElementById("score");
const statusText = document.getElementById("status");
const restartButton = document.getElementById("restart");
const socket = window.io();

Why reference the restart button?

The client needs a direct reference to the existing hidden button. It can reveal that button after an authoritative winner arrives.

  • Find the socket.on("state") handler.
  • Replace its HUD update section with this authoritative match-state section:
  flameTarget = state.flame;
  scoreText.textContent = `Azure ${state.scores.azure} | ${state.scores.crimson} Crimson`;

  if (state.winner) {
    statusText.textContent = `${teamLabel(state.winner)} guardians sealed the rival shrine.`;
    restartButton.hidden = false;
  } else if (state.flame.carrierId === myId) {
    statusText.textContent = "You carry the Spirit Flame. Reach the rival shrine.";
    restartButton.hidden = true;
  } else if (state.flame.carrierId) {
    const carrier = state.players.find((player) => player.id === state.flame.carrierId);
    statusText.textContent = `${teamLabel(carrier?.team)} carries the Spirit Flame.`;
    restartButton.hidden = true;
  } else if (myTeam) {
    statusText.textContent = `You are ${teamLabel(myTeam)}. Claim the Spirit Flame.`;
    restartButton.hidden = true;
  }
});

How does the HUD choose a message?

  • A winner takes priority because the match has ended.
  • A carrier message comes next because the objective is active.
  • The team assignment appears while the flame is waiting at the center.
  • Save public/game.js.
  • Refresh Microsoft Edge on Windows.

You should see the score remain at Azure 0 | 0 Crimson. Your status line should identify your assigned team.

  • Add the restart request at the bottom of public/game.js:
restartButton.addEventListener("click", () => {
  socket.emit("restart");
});

What does the button send?

The button asks the server to restart the match. The client does not reset its own score or position.

  • Save public/game.js.
  • Refresh the game in Microsoft Edge.
  • Refresh the LAN game page in Android Chrome.
  • Refresh the LAN game page in Safari on your iPhone or iPad.

Every screen should show the same score. Each screen should also show a status message based on its assigned team.

HUD not updating?

Confirm that flameTarget = state.flame remains inside the state handler. Check that the handler closes after the final team-status branch.

Refresh every browser after saving the file. Mobile browsers may still have the previous client script loaded.

Help me debug the Spirit Flame HUD.

Use the full client file below to check every HUD update plus the existing movement logic.

✔️ Awesome, I've got everything!

Your client now renders the complete shared match state.

ⓧ I'd like to double check the full code

import * as pc from "/playcanvas.mjs";

const canvas = document.getElementById("game");
const scoreText = document.getElementById("score");
const statusText = document.getElementById("status");
const restartButton = document.getElementById("restart");
const socket = window.io();

const app = new pc.Application(canvas);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
window.addEventListener("resize", () => app.resizeCanvas());
app.start();

function makeMaterial(red, green, blue, glow = 0) {
  const material = new pc.StandardMaterial();
  material.diffuse.set(red, green, blue);
  material.emissive.set(red * glow, green * glow, blue * glow);
  material.gloss = 0.35;
  material.update();
  return material;
}

const materials = {
  stone: makeMaterial(0.18, 0.2, 0.28),
  path: makeMaterial(0.32, 0.3, 0.42),
  wood: makeMaterial(0.32, 0.12, 0.09),
  roof: makeMaterial(0.08, 0.06, 0.13),
  azure: makeMaterial(0.08, 0.55, 0.95, 0.18),
  crimson: makeMaterial(0.95, 0.18, 0.28, 0.16),
  flame: makeMaterial(1, 0.72, 0.16, 0.8),
  gold: makeMaterial(0.95, 0.72, 0.2, 0.22)
};

function worldPrimitive(name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  app.root.addChild(entity);
  return entity;
}

function childPrimitive(parent, name, type, scale, position, material) {
  const entity = new pc.Entity(name);
  entity.addComponent("render", { type });
  entity.setLocalScale(scale[0], scale[1], scale[2]);
  entity.setLocalPosition(position[0], position[1], position[2]);
  entity.render.material = material;
  parent.addChild(entity);
  return entity;
}

const camera = new pc.Entity("Camera");
camera.addComponent("camera", {
  clearColor: new pc.Color(0.025, 0.035, 0.09)
});
camera.setPosition(0, 12, 13);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);

const light = new pc.Entity("Moon Light");
light.addComponent("light");
light.setEulerAngles(48, 35, 0);
app.root.addChild(light);

worldPrimitive("Ground", "box", [16, 0.4, 10], [0, -0.25, 0], materials.stone);
worldPrimitive("Moon Path", "box", [15, 0.06, 2.6], [0, 0, 0], materials.path);
worldPrimitive("Central Seal", "torus", [3.2, 0.35, 3.2], [0, 0.18, 0], materials.gold);

for (let x = -6; x <= 6; x += 2) {
  worldPrimitive(`North Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, -4.65], materials.stone);
  worldPrimitive(`South Stone ${x}`, "box", [0.5, 0.6, 0.5], [x, 0.15, 4.65], materials.stone);
}

function buildShrine(team, centerX, material) {
  worldPrimitive(`${team} Shrine Base`, "cylinder", [2.8, 0.35, 2.8], [centerX, 0.3, 0], material);
  worldPrimitive(`${team} Shrine Ring`, "torus", [3.6, 0.4, 3.6], [centerX, 0.5, 0], material);
  worldPrimitive(`${team} Pillar North`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, -1.05], materials.wood);
  worldPrimitive(`${team} Pillar South`, "box", [0.35, 2.6, 0.35], [centerX, 1.35, 1.05], materials.wood);
  worldPrimitive(`${team} Shrine Roof`, "box", [1.6, 0.25, 3], [centerX, 2.65, 0], materials.roof);
}

buildShrine("Azure", -7, materials.azure);
buildShrine("Crimson", 7, materials.crimson);

const flameEntity = worldPrimitive("Spirit Flame", "sphere", [0.75, 0.75, 0.75], [0, 1.15, 0], materials.flame);
const playerEntities = new Map();
const targetPositions = new Map();
let flameTarget = { x: 0, y: 1.15, z: 0 };
let myId = null;
let myTeam = null;

function createGuardian(player) {
  const guardian = new pc.Entity(`Guardian ${player.id}`);
  const teamMaterial = player.team === "azure" ? materials.azure : materials.crimson;

  childPrimitive(guardian, "Body", "capsule", [0.85, 1.25, 0.85], [0, 0, 0], teamMaterial);
  childPrimitive(guardian, "Mask", "sphere", [0.7, 0.55, 0.5], [0, 0.65, -0.2], materials.gold);
  childPrimitive(guardian, "Crown", "cone", [0.75, 0.9, 0.75], [0, 1.3, 0], teamMaterial);

  guardian.setPosition(player.x, 0.85, player.z);
  app.root.addChild(guardian);
  return guardian;
}

function teamLabel(team) {
  return team === "azure" ? "Azure" : "Crimson";
}

socket.on("connect", () => {
  statusText.textContent = "Connected. Waiting for your clan assignment...";
});

socket.on("connect_error", () => {
  statusText.textContent = "Connection failed. Check the server address and Windows Firewall.";
});

socket.on("disconnect", () => {
  statusText.textContent = "Disconnected. The client will try to reconnect.";
});

socket.on("identity", (identity) => {
  myId = identity.id;
  myTeam = identity.team;
});

socket.on("state", (state) => {
  const activeIds = new Set();

  for (const player of state.players) {
    activeIds.add(player.id);
    if (!playerEntities.has(player.id)) {
      playerEntities.set(player.id, createGuardian(player));
    }
    targetPositions.set(player.id, player);
  }

  for (const [id, entity] of playerEntities) {
    if (!activeIds.has(id)) {
      entity.destroy();
      playerEntities.delete(id);
      targetPositions.delete(id);
    }
  }

  flameTarget = state.flame;
  scoreText.textContent = `Azure ${state.scores.azure} | ${state.scores.crimson} Crimson`;

  if (state.winner) {
    statusText.textContent = `${teamLabel(state.winner)} guardians sealed the rival shrine.`;
    restartButton.hidden = false;
  } else if (state.flame.carrierId === myId) {
    statusText.textContent = "You carry the Spirit Flame. Reach the rival shrine.";
    restartButton.hidden = true;
  } else if (state.flame.carrierId) {
    const carrier = state.players.find((player) => player.id === state.flame.carrierId);
    statusText.textContent = `${teamLabel(carrier?.team)} carries the Spirit Flame.`;
    restartButton.hidden = true;
  } else if (myTeam) {
    statusText.textContent = `You are ${teamLabel(myTeam)}. Claim the Spirit Flame.`;
    restartButton.hidden = true;
  }
});

app.on("update", (dt) => {
  const blend = Math.min(1, dt * 10);

  for (const [id, entity] of playerEntities) {
    const target = targetPositions.get(id);
    if (!target) continue;

    const current = entity.getPosition();
    entity.setPosition(
      current.x + (target.x - current.x) * blend,
      0.85,
      current.z + (target.z - current.z) * blend
    );
  }

  const flamePosition = flameEntity.getPosition();
  flameEntity.setPosition(
    flamePosition.x + (flameTarget.x - flamePosition.x) * blend,
    flamePosition.y + (flameTarget.y - flamePosition.y) * blend,
    flamePosition.z + (flameTarget.z - flamePosition.z) * blend
  );
  flameEntity.rotate(0, 90 * dt, 0);
});

const heldKeys = new Set();
const heldPointers = new Map();
const movementKeys = new Set([
  "KeyW", "KeyA", "KeyS", "KeyD",
  "ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight"
]);

window.addEventListener("keydown", (event) => {
  if (movementKeys.has(event.code)) {
    heldKeys.add(event.code);
    event.preventDefault();
  }
});

window.addEventListener("keyup", (event) => {
  heldKeys.delete(event.code);
});

for (const button of document.querySelectorAll(".control")) {
  button.addEventListener("pointerdown", (event) => {
    event.preventDefault();
    heldPointers.set(event.pointerId, {
      x: Number(button.dataset.x),
      z: Number(button.dataset.z)
    });
  });
}

function releasePointer(event) {
  heldPointers.delete(event.pointerId);
}

window.addEventListener("pointerup", releasePointer);
window.addEventListener("pointercancel", releasePointer);

function currentInput() {
  let x = 0;
  let z = 0;

  if (heldKeys.has("KeyA") || heldKeys.has("ArrowLeft")) x -= 1;
  if (heldKeys.has("KeyD") || heldKeys.has("ArrowRight")) x += 1;
  if (heldKeys.has("KeyW") || heldKeys.has("ArrowUp")) z -= 1;
  if (heldKeys.has("KeyS") || heldKeys.has("ArrowDown")) z += 1;

  for (const direction of heldPointers.values()) {
    x += direction.x;
    z += direction.z;
  }

  const magnitude = Math.hypot(x, z);
  if (magnitude > 1) {
    x /= magnitude;
    z /= magnitude;
  }

  return { x, z };
}

setInterval(() => {
  if (socket.connected) {
    socket.emit("input", currentInput());
  }
}, 50);

restartButton.addEventListener("click", () => {
  socket.emit("restart");
});

How to use this reference

Compare the state handler plus restart listener with your saved file. Keep the existing scene plus input sections in the same order.

Play the complete match

The final check follows the full match loop. You will claim the central flame. You will score three times before restarting the duel.

Before you test the objective, do you expect every device to name the same carrier after one guardian touches the flame?

  • Move one guardian into the central Spirit Flame.

Every screen should show that one team carries the flame. The glowing flame should follow the same guardian on every device.

  • Carry the flame into the opposing shrine.

Every screen should show the same updated score. All guardians should return to their team spawn positions.

  • Repeat the scoring run until one team reaches three points.

Every screen should name the same winning team. Movement should stop while the restart button becomes visible.

  • Select Start another duel on one device.

All screens should return to a zero-zero score. The flame should return to the central seal while every guardian returns to a spawn lane.

Match state differs between devices?

Confirm that every device still uses the same Windows IPv4 LAN URL. Edge on the Windows PC may continue using the local address.

Check that PowerShell still shows the running server process. Refresh each browser after any client code change.

Help me debug mismatched cross-device game state.

You completed the core match. Your Windows PC now hosts one objective-driven 3D game that stays synchronized across desktop plus mobile browsers.

Secret mission

Add a Server-Authoritative Spirit Dash

Give each guardian a burst of speed without handing control to the browser. You will add keyboard and double-tap controls while the server enforces dash distance, arena bounds, and a three-second cooldown.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your local game ready for another session, pause the server, or remove the project entirely. Everything runs locally at a cost of $0.

Resources you used:

  • Local project folder: The spirit-shrine-clash folder containing your server, browser files, and project configuration.
  • Installed dependency files: Local npm packages inside node_modules plus the generated package-lock.json file.
  • Active game server process: The Node.js server currently running in Windows PowerShell.
  • Optional Windows Firewall rule: The Node.js allowed-app entry limited to the Private network profile.

Keep everything running

Choose this option if you plan to host more LAN matches soon. Your game files stay ready on this Windows PC.

  • Return to the PowerShell terminal from earlier.
  • Press Ctrl+C to stop the game server when you finish playing.
  • Keep the spirit-shrine-clash folder for your next development session.
  • Retain the optional Node.js firewall permission on the Private profile for future LAN testing.

Your project remains ready to restart whenever you want another cross-device match. No local resource incurs an ongoing cost while the server is stopped.

Pause - I'll come back to this later

Pause the live server while keeping every project file. This frees the running process without changing your game.

  • Switch back to the PowerShell terminal from earlier.
  • Press Ctrl+C to stop the Node.js process.
  • Leave the spirit-shrine-clash folder on your PC.
  • Leave the optional Private-profile firewall permission in place if you expect to resume LAN testing.

The PowerShell prompt returns when the server stops. The Windows, Android, and iPhone or iPad browsers can no longer reach the game until you start it again.

Delete - I don't want to use this again

Deleting the folder becomes permanent after you empty Recycle Bin. Your installed Windows tools remain available for other projects.

  • Return to the PowerShell terminal from earlier.
  • Press Ctrl+C to stop the running server.
  • Close the PowerShell window.

That closes the live LAN match cleanly. You can now remove the local project files.

  • Press the Windows key to open Windows search.
  • Type File Explorer into Windows search.
  • Select File Explorer from the search results.
  • Search for spirit-shrine-clash in File Explorer.
  • Select the spirit-shrine-clash folder in the results.
  • Press Delete to remove the selected folder.
  • Empty Recycle Bin to remove the project files permanently.

Deleting the project folder also removes node_modules and package-lock.json.

The firewall entry only exists if you approved it during LAN setup. Remove that permission if you no longer plan to host this game on your local network.

  • Press the Windows key to open Windows search.
  • Type Windows Security into Windows search.
  • Select Windows Security from the search results.
  • Select Firewall & network protection.
  • Select Allow an app through firewall.
  • Select Change settings.
  • Clear the Node.js check box.
  • Select OK.

The local game files are gone once the folder is absent from File Explorer. The LAN permission is gone once Node.js is no longer allowed through Windows Firewall.

Nice Work!

Nice Work!

You did it! Spirit Shrine Clash is now a complete 3D objective game. Every connected screen shares the same guardians, Spirit Flame, score, victory state, restart, and dash.

What you learned:

  • Built a mirrored 3D shrine arena with PlayCanvas Engine. Added responsive keyboard and touch controls for desktop plus mobile browsers.
  • Implemented a server-authoritative multiplayer model in Node.js. Synchronized guardian movement across every connected screen with Socket.IO.
  • Completed the Spirit Flame match loop with contested pickup, rival-shrine scoring, first-to-three victory, round resets, and match restarts. Verified the shared game state across Windows, Android, and iPhone/iPad over a local-area network.
  • Secret Mission: Added a server-authoritative Spirit Dash with Space-key and double-tap controls. Enforced arena bounds plus a three-second server cooldown that blocks repeated client requests.

Ready to quiz yourself?