ExamplesPlane Rings

Build a Multiplayer Airplane Ring Game with React Three Fiber and Playroom Kit

Learn how to build a multiplayer airplane ring-passing game using React Three Fiber for 3D rendering and Playroom Kit for real-time multiplayer functionality. Players fly vintage toy airplanes through rings while competing against each other.




Airplane.jsx
Loading...

Getting Started

This tutorial shows you how to create a multiplayer airplane game where players navigate through floating rings in a 3D environment. The game uses Playroom Kit to handle multiplayer networking without requiring a custom backend server via insertCoin. Each player controls their own airplane that syncs in real-time across all connected players using setState and getState.

The game features a vintage toy airplane model, reflective water surfaces, dynamic camera following, motion blur effects based on speed, and a ring-target system where players score by flying through rings. All player positions, rotations, and game state are synchronized through Playroom Kit’s state management using usePlayersList and useMultiplayerState.

Vibe Coding System Prompt

Vibe-code friendly

If you are using an AI coding tool, copy this prompt:


You are a senior software engineer at Little Umbrella specializing in building real-time multiplayer applications using Playroom Kit and React.Your task is to help me build a "Multiplayer Airplane Ring Game".The application should be created using React as a frontend framework and Playroom Kit as a multiplayer framework.Do not assume the existence of any Playroom Kit APIs or hooks. Only use APIs documented in the official Playroom Kit documentation.I already have a React project set up.I will provide step-by-step instructions. After each step, clearly explain what was implemented and ask if I want customizations before proceeding.

You can adjust the prompt according to whether or not you have an existing application.

Guide

If you haven’t created the project yet, set up a new React project with Vite before moving to the first step. You’ll need to install the necessary dependencies including React Three Fiber, Drei, postprocessing, and Playroom Kit.

If you’re vibe-coding, start by copying the Vibe Coding System prompt. Then copy each step one by one into your coding assistant by clicking “Copy Prompt”.

Step 1: Set Up the Project with Dependencies  

Set up the React project with all necessary dependencies for 3D rendering and multiplayer functionality.

main.jsx
import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { Canvas } from "@react-three/fiber";
import "./index.css";
 
ReactDOM.createRoot(document.getElementById("root")).render(
  <Canvas shadows>
    <Suspense fallback={null}>
      <App />
    </Suspense>
  </Canvas>
);

Step 2: Initialize Playroom Kit in App.jsx  

Initialize Playroom Kit to handle multiplayer connections and authentication using insertCoin and usePlayersList.

App.jsx
import React, { useEffect, useState } from "react";
import { PerspectiveCamera, Environment } from "@react-three/drei";
import { EffectComposer, HueSaturation } from "@react-three/postprocessing";
import { BlendFunction } from "postprocessing";
import { Landscape } from "./Landscape";
import { SphereEnv } from "./SphereEnv";
import { Airplane } from "./Airplane";
import { Targets } from "./Targets";
import { MotionBlur } from "./MotionBlur";
import { Joystick, insertCoin, myPlayer, usePlayersList } from "playroomkit";
 
function App() {
  const [coinInserted, setCoinInserted] = useState(false);
  useEffect(() => {
    insertCoin().then(() => {
      setCoinInserted(true);
    });
  }, []);
 
  if (!coinInserted) {
    return null;
  }
  return (
    <Scene />
  );
}
 
function Scene(){
  const players = usePlayersList();
  const [joystick, setJoystick] = useState();
 
  useEffect(() => {
    if (!joystick){
      setJoystick(new Joystick(myPlayer(), {
        type: "angular",
        buttons: [
          {id: "boost", label: "Boost"},
          {id: "reset", label: "Reset"}
        ]
      }));
    }
  }, [joystick]);
 
  return (
    <>
      <SphereEnv />
      <Environment background={false} files={"assets/textures/envmap.hdr"} />
 
      <PerspectiveCamera makeDefault position={[0, 10, 10]} />
 
      <Landscape />
      {players.map((player) => 
        <Airplane key={player.id} player={player} joystick={player.id===myPlayer().id? joystick: undefined} />
      )}
      <Targets />
 
      <directionalLight
        castShadow
        color={"#f3d29a"}
        intensity={2}
        position={[10, 5, 4]}
        shadow-bias={-0.0005}
        shadow-mapSize-width={1024}
        shadow-mapSize-height={1024}
        shadow-camera-near={0.01}
        shadow-camera-far={20}
        shadow-camera-top={6}
        shadow-camera-bottom={-6}
        shadow-camera-left={-6.2}
        shadow-camera-right={6.4}
      />
 
      <EffectComposer>
        <MotionBlur />
        <HueSaturation
          blendFunction={BlendFunction.NORMAL}
          hue={-0.15}
          saturation={0.1}
        />
      </EffectComposer>
    </>
  );
}
 
export default App;

Step 3: Create the Controls Module  

Create the controls module to handle keyboard input and airplane movement physics.

controls.js
function easeOutQuad(x) {
  return 1 - (1 - x) * (1 - x);
}
 
export let controls = {};
 
window.addEventListener("keydown", (e) => {
  controls[e.key.toLowerCase()] = true;
});
window.addEventListener("keyup", (e) => {
  controls[e.key.toLowerCase()] = false;
});
 
let maxVelocity = 0.04;
let jawVelocity = 0;
let pitchVelocity = 0;
let planeSpeed = 0.006;
export let turbo = 0;
 
export function updatePlaneAxis(x, y, z, planePosition, camera, joystick) {
  jawVelocity *= 0.95;
  pitchVelocity *= 0.95;
 
  const angle = joystick.angle();
  const yAxis = joystick.isJoystickPressed() ? Math.cos(angle): 0;
  const xAxis = joystick.isJoystickPressed() ? Math.sin(angle) * -1: 0;
 
  if (Math.abs(jawVelocity) > maxVelocity) 
    jawVelocity = Math.sign(jawVelocity) * maxVelocity;
 
  if (Math.abs(pitchVelocity) > maxVelocity) 
    pitchVelocity = Math.sign(pitchVelocity) * maxVelocity;
 
 
  jawVelocity += xAxis * 0.0015;
  pitchVelocity += yAxis * 0.0005;
 
  if (controls["a"]) {
    jawVelocity += 0.0025;
  }
 
  if (controls["d"]) {
    jawVelocity -= 0.0025;
  }
 
  if (controls["w"]) {
    pitchVelocity -= 0.0025;
  }
 
  if (controls["s"]) {
    pitchVelocity += 0.0025;
  }
 
  if (controls["r"] || joystick.isPressed('reset')) {
    jawVelocity = 0;
    pitchVelocity = 0;
    turbo = 0;
    x.set(1, 0, 0);
    y.set(0, 1, 0);
    z.set(0, 0, 1);
    planePosition.set(0, 3, 7);
  }
 
  x.applyAxisAngle(z, jawVelocity);
  y.applyAxisAngle(z, jawVelocity);
 
  y.applyAxisAngle(x, pitchVelocity);
  z.applyAxisAngle(x, pitchVelocity);
 
  x.normalize();
  y.normalize();
  z.normalize();
 
 
  if (controls.shift || joystick.isPressed('boost')) {
    turbo += 0.025;
  } else {
    turbo *= 0.95;
  }
  turbo = Math.min(Math.max(turbo, 0), 1);
 
  let turboSpeed = easeOutQuad(turbo) * 0.02;
 
  camera.fov = 45 + turboSpeed * 900;
  camera.updateProjectionMatrix();
 
  planePosition.add(z.clone().multiplyScalar(-planeSpeed -turboSpeed));
}

Step 4: Create the Airplane Component  

Create the Airplane component that renders the 3D airplane model and handles multiplayer synchronization using myPlayer, setState, and getState.

Airplane.jsx
import React, { useRef } from 'react'
import { useGLTF } from '@react-three/drei'
import { useFrame } from '@react-three/fiber';
import { Matrix4, Quaternion, Vector3 } from 'three';
import { updatePlaneAxis } from './controls';
import { myPlayer } from 'playroomkit';
import { useEffect, useState } from 'react';
import * as THREE from 'three';
 
const x = new Vector3(1, 0, 0);
const y = new Vector3(0, 1, 0);
const z = new Vector3(0, 0, 1);
export const planePosition = new Vector3(0, 3, 7);
 
const delayedRotMatrix = new Matrix4();
const delayedQuaternion = new Quaternion();
 
export function Airplane({ player, joystick, ...props }) {
  
  const [thisIsMyPlane, setThisIsMyPlane] = useState(false);
  const [myColorMaterial, setMyColorMaterial] = useState();
  useEffect(() => {
    const me = myPlayer();
    setThisIsMyPlane(player.id === me.id);
    const color = player.getProfile().color;
    if (color) {
      const material = new THREE.MeshStandardMaterial();
      material.color.setHex(color.hex);
      setMyColorMaterial(material);
    }
  }, [player]);
 
  const { nodes, materials } = useGLTF('assets/models/airplane.glb');
  const groupRef = useRef();
  const helixMeshRef = useRef();
 
  useFrame(({ camera }) => {
    
 
    helixMeshRef.current.rotation.z -= 1.0;
 
    if (thisIsMyPlane) {
 
    updatePlaneAxis(x, y, z, planePosition, camera, joystick);
 
    const rotMatrix = new Matrix4().makeBasis(x, y, z);
 
    const matrix = new Matrix4()
    .multiply(new Matrix4().makeTranslation(planePosition.x, planePosition.y, planePosition.z))
    .multiply(rotMatrix);
 
    groupRef.current.matrixAutoUpdate = false;
    groupRef.current.matrix.copy(matrix);
    groupRef.current.matrixWorldNeedsUpdate = true;
 
 
    var quaternionA = new Quaternion().copy(delayedQuaternion);
 
    var quaternionB = new Quaternion();
    quaternionB.setFromRotationMatrix(rotMatrix);
 
    var interpolationFactor = 0.175;
    var interpolatedQuaternion = new Quaternion().copy(quaternionA);
    interpolatedQuaternion.slerp(quaternionB, interpolationFactor);
    delayedQuaternion.copy(interpolatedQuaternion);
 
    delayedRotMatrix.identity();
    delayedRotMatrix.makeRotationFromQuaternion(delayedQuaternion);
 
        const cameraMatrix = new Matrix4()
        .multiply(new Matrix4().makeTranslation(planePosition.x, planePosition.y, planePosition.z))
        .multiply(delayedRotMatrix)
        .multiply(new Matrix4().makeRotationX(-0.2))
        .multiply(
          new Matrix4().makeTranslation(0, 0.015, 0.76)
        );
 
      camera.matrixAutoUpdate = false;
      camera.matrix.copy(cameraMatrix);
      camera.matrixWorldNeedsUpdate = true; 
 
      const position = new Vector3();
      const quaternion = new Quaternion();
      groupRef.current.getWorldPosition(position);
      groupRef.current.getWorldQuaternion(quaternion);
      player.setState("position", position);
      player.setState("x", x);
      player.setState("y", y);
      player.setState("z", z);
    }
    else{
      const position = player.getState("position");
      const x = player.getState("x");
      const y = player.getState("y");
      const z = player.getState("z");
      if (position && x && y && z) {
        const rotMatrix = new Matrix4().makeBasis(x, y, z);
      const matrix = new Matrix4()
    .multiply(new Matrix4().makeTranslation(position.x, position.y, position.z))
    .multiply(rotMatrix);
 
    groupRef.current.matrixAutoUpdate = false;
    groupRef.current.matrix.copy(matrix);
    groupRef.current.matrixWorldNeedsUpdate = true;
      }
    }
  });
 
  return (
    <>
      <group ref={groupRef}>
        <group {...props} dispose={null} scale={0.01} rotation-y={Math.PI}>
          <mesh geometry={nodes.supports.geometry} material={materials['Material.004']} />
          <mesh geometry={nodes.chassis.geometry} material={myColorMaterial} />
          <mesh geometry={nodes.helix.geometry} material={materials['Material.005']} ref={helixMeshRef} />
        </group>
      </group>
    </>
  )
}
 
useGLTF.preload('assets/models/airplane.glb');

Step 5: Create the Landscape Component  

Create the Landscape component with the 3D environment and reflective water.

Landscape.jsx
import React, { useEffect, useMemo } from "react";
import { MeshReflectorMaterial, useGLTF } from "@react-three/drei";
import { Color, MeshStandardMaterial } from "three";
 
export function Landscape(props) {
  const { nodes, materials } = useGLTF("assets/models/scene.glb");
 
  const [lightsMaterial, waterMaterial] = useMemo(() => {
    return [
      new MeshStandardMaterial({
        envMapIntensity: 0,
        color: new Color("#ea6619"),
        roughness: 0,
        metalness: 0,
        emissive: new Color("#f6390f").multiplyScalar(1),
      }),
      <MeshReflectorMaterial
        transparent={true}
        opacity={0.6}
        color={"#23281b"}
        roughness={0}
        blur={[10, 10]}
        mixBlur={1}
        mixStrength={20}
        mixContrast={1.2}
        resolution={512}
        mirror={0}
        depthScale={0}
        minDepthThreshold={0}
        maxDepthThreshold={0.1}
        depthToBlurRatioBias={0.0025}
        debug={0}
        reflectorOffset={0.0}
      />,
    ];
  }, []);
 
  useEffect(() => {
    const landscapeMat = materials["Material.009"];
    landscapeMat.envMapIntensity = 0.75;
 
    const treesMat = materials["Material.008"];
    treesMat.color = new Color("#2f2f13");
    treesMat.envMapIntensity = 0.3;
    treesMat.roughness = 1;
    treesMat.metalness = 0;
  }, [materials]);
 
  return (
    <group {...props} dispose={null}>
      <mesh
        geometry={nodes.landscape_gltf.geometry}
        material={materials["Material.009"]}
        castShadow
        receiveShadow
      />
      <mesh
        geometry={nodes.landscape_borders.geometry}
        material={materials["Material.010"]}
      />
      <mesh
        geometry={nodes.trees_light.geometry}
        material={materials["Material.008"]}
        castShadow
        receiveShadow
      />
      <mesh
        position={[-2.536, 1.272, 0.79]}
        rotation={[-Math.PI * 0.5, 0, 0]}
        scale={[1.285, 1.285, 1]}
      >
        <planeGeometry args={[1, 1]} />
        {waterMaterial}
      </mesh>
      <mesh
        position={[1.729, 0.943, 2.709]}
        rotation={[-Math.PI * 0.5, 0, 0]}
        scale={[3, 3, 1]}
      >
        <planeGeometry args={[1, 1]} />
        {waterMaterial}
      </mesh>
      <mesh
        position={[0.415, 1.588, -2.275]}
        rotation={[-Math.PI * 0.5, 0, 0]}
        scale={[3.105, 2.405, 1]}
      >
        <planeGeometry args={[1, 1]} />
        {waterMaterial}
      </mesh>
      <mesh
        geometry={nodes.lights.geometry}
        material={lightsMaterial}
        castShadow
      />
    </group>
  );
}
 
useGLTF.preload("assets/models/scene.glb");

Step 6: Create the SphereEnv Component  

Create the SphereEnv component for the skybox environment.

SphereEnv.jsx
import { useTexture } from "@react-three/drei";
import { BackSide } from "three";
 
export function SphereEnv() {
  const map = useTexture("assets/textures/envmap.jpg");
 
  return <mesh>
    <sphereGeometry args={[60, 50, 50]} />
    <meshBasicMaterial 
      side={BackSide}
      map={map}
    />
  </mesh>
}

Step 7: Create the Targets Component  

Create the Targets component with ring targets and collision detection using useMultiplayerState and isHost.

Targets.jsx
import { useState, useMemo, useEffect } from "react";
import { Quaternion, TorusGeometry, Vector3 } from "three";
import { mergeBufferGeometries } from "three-stdlib";
import { useFrame } from "@react-three/fiber";
import { planePosition } from "./Airplane";
import { isHost, useMultiplayerState } from "playroomkit";
 
function randomPoint(scale) {
  return new Vector3(
    Math.random() * 2 - 1,
    Math.random() * 2 - 1,
    Math.random() * 2 - 1
  ).multiply(scale || new Vector3(1, 1, 1));
}
 
const TARGET_RAD = 0.125;
 
export function Targets() {
  const [targets, setTargets] = useMultiplayerState("targets", []);
  useEffect(() => {
    if (targets.length === 0 && isHost()) {
      const arr = [];
      for (let i = 0; i < 25; i++) {
        arr.push({
          center: randomPoint(new Vector3(4, 1, 4)).add(
            new Vector3(0, 2 + Math.random() * 2, 0)
          ),
          direction: randomPoint().normalize(),
          hit: false,
        });
      }
 
      setTargets(arr);
    }
  }, []);
 
  const geometry = useMemo(() => {
    let geo;
 
    targets.forEach((target) => {
      const torusGeo = new TorusGeometry(TARGET_RAD, 0.02, 8, 25);
      torusGeo.applyQuaternion(
        new Quaternion().setFromUnitVectors(
          new Vector3(0, 0, 1),
          target.direction
        )
      );
      torusGeo.translate(target.center.x, target.center.y, target.center.z);
 
      if (!geo) geo = torusGeo;
      else geo = mergeBufferGeometries([geo, torusGeo]);
    });
 
    return geo;
  }, [targets]);
 
  useFrame(() => {
    targets.forEach((target, i) => {
      const v = planePosition.clone().sub(target.center);
      const direction = new Vector3().copy(target.direction).normalize();
      const dist = direction.dot(v);
      const projected = planePosition
        .clone()
        .sub(direction.clone().multiplyScalar(dist));
 
      const hitDist = projected.distanceTo(new Vector3().copy(target.center));
      if (hitDist < TARGET_RAD) {
        target.hit = true;
      }
    });
 
    const atLeastOneHit = targets.find((target) => target.hit);
    if (atLeastOneHit) {
      setTargets(targets.filter((target) => !target.hit));
    }
  });
 
  return (
    <mesh geometry={geometry}>
      <meshStandardMaterial roughness={0.5} metalness={0.5} />
    </mesh>
  );
}

Step 8: Create the MotionBlur Component  

Create the MotionBlur component for speed-based post-processing effects.

MotionBlur.jsx
import React, { forwardRef, useMemo } from 'react';
import { Uniform } from 'three';
import { Effect } from 'postprocessing';
import { turbo } from './controls';
 
const fragmentShader = `
uniform float strength;
 
float rand2 (vec2 n) { 
	return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453);
}
 
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
  vec2 aspectCorrection = vec2(1.0, aspect);
 
  vec2 dir = normalize(uv - vec2(0.5));
  float dist = length(uv - vec2(0.5));
  float positionalStrength = max(dist - 0.1, 0.0) * 0.1;
  positionalStrength = pow(positionalStrength, 1.5) * 7.0;
 
  vec4 accum = vec4(0.0);
  for (int i = 0; i < 7; i++) {
    vec2 offs1 = -dir * positionalStrength * strength * ((float(i) + rand2(uv * 5.0)) * 0.2);
    vec2 offs2 = dir * positionalStrength * strength * ((float(i) + rand2(uv * 5.0)) * 0.2);
 
    accum += texture2D(inputBuffer, uv + offs1);
    accum += texture2D(inputBuffer, uv + offs2);
  }
  accum *= 1.0 / 14.0;
 
	outputColor = accum;
}`
 
class MotionBlurImpl extends Effect {
  constructor() {
    super('MotionBlur', fragmentShader, {
      uniforms: new Map([['strength', new Uniform(0)]]),
    })
  }
 
  update(renderer, inputBuffer, deltaTime) {
    this.uniforms.get('strength').value = turbo;
  }
}
 
export const MotionBlur = forwardRef(({ }, ref) => {
  const effect = useMemo(() => new MotionBlurImpl(), [])
  return <primitive ref={ref} object={effect} dispose={null} />
});

Improvements

  • Add scoring system to track how many rings each player has collected
  • Implement respawning rings so players can continue scoring
  • Add particle effects when flying through rings
  • Create a leaderboard UI to show player scores
  • Add sound effects for engine, ring collection, and background music
  • Implement different airplane skins or colors for players