Build a Cars On Roof Multiplayer Game with Three.js and Playroom Kit
Learn how to build a multiplayer isometric car driving game where cars race on a roof. You’ll use Three.js for 3D rendering, Cannon.js for physics, and Playroom Kit for real-time multiplayer synchronization.
Getting Started
This tutorial shows you how to build a “Cars On Roof” game where multiple players drive cars on an isometric rooftop arena. Players control their cars using Joystick controls, and physics are synchronized across all players in real-time using Playroom Kit’s state management via setState and getState.
The game features realistic car physics with suspension, wheel steering, and acceleration. Each player gets their own car with a unique color from their Playroom profile. The host device acts as the authoritative source for physics calculations using isHost, while other clients receive position updates.
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 Three.js.Your task is to help me build a "Cars On Roof" driving game.The application should be created using Three.js as a 3D rendering library, Cannon.js for physics, 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 will provide step-by-step instructions. After each step, clearly explain what was implemented and ask if I want customizations before proceeding.Important: Do not write any code yourself. Only provide guidance and code snippets based on my instructions.
You can adjust the prompt according to whether or not you have an existing application.
Guide
If you haven’t cloned the starter repository yet, clone it before moving to the first step. The starter code contains basic project setup with Three.js, Cannon.js, and Playroom Kit dependencies.
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: Initialize Playroom Kit and Set Up the Scene
Set up the basic Three.js scene with lighting and the WebGL renderer using insertCoin. This creates the foundation for the 3D isometric car game.
import { onPlayerJoin, insertCoin, isHost, myPlayer, setState, getState, Joystick } from "playroomkit";
import * as THREE from 'three';
import * as CANNON from 'cannon';
import Time from "./utils/Time"
import Car from "./car";
import shape2mesh from "./utils/shape2mesh";
import createWorld from "./world";
import loadCar from './carmodel';
import mobileRevTriangle from './images/trianglerev.png'
function setupGame() {
// Init world
const scene = new THREE.Scene()
const hemisphereLight = new THREE.HemisphereLight(0xaaaaff, 0xffaa00, .4);
const ambientLight = new THREE.AmbientLight(0xdc8874, .4);
const shadowLight = new THREE.DirectionalLight(0xffffff, .9);
shadowLight.position.set(150, 350, 350);
shadowLight.castShadow = true;
shadowLight.shadow.camera.left = -400;
shadowLight.shadow.camera.right = 400;
shadowLight.shadow.camera.top = 400;
shadowLight.shadow.camera.bottom = -400;
shadowLight.shadow.camera.near = 1;
shadowLight.shadow.camera.far = 1000;
shadowLight.shadow.mapSize.width = 2048;
shadowLight.shadow.mapSize.height = 2048;
scene.add(hemisphereLight);
scene.add(shadowLight);
scene.add(ambientLight);
scene.fog = new THREE.Fog(0xf7d9aa, 100, 950);
const light = new THREE.DirectionalLight(0xffffff, 0.5);
light.position.set(100, 100, 50);
light.castShadow = true;
const dLight = 200;
const sLight = dLight * 0.25;
light.shadow.camera.left = - sLight;
light.shadow.camera.right = sLight;
light.shadow.camera.top = sLight;
light.shadow.camera.bottom = - sLight;
light.shadow.camera.near = dLight / 30;
light.shadow.camera.far = dLight;
light.shadow.mapSize.x = 1024 * 2;
light.shadow.mapSize.y = 1024 * 2;
scene.add(light);
// Renderer
const renderer = new THREE.WebGLRenderer({
alpha: true
});
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0xb4e0f1, 1)
renderer.setPixelRatio(2)
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement);
// Camera
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 1, 80);
const cameraCoords = new THREE.Vector3(19.36, 9.36, 11.61);
camera.position.copy(cameraCoords);
camera.lookAt(new THREE.Vector3(0, 0, 0));
camera.rotation.x = -0.7;
camera.rotation.y = 1;
camera.rotation.z = 2.37;
window.camera = camera;
// Physics
let time = new Time();
const PhysicsWorld = new CANNON.World({
gravity: new CANNON.Vec3(0, -9.83, 0)
});
// Floor and walls
createWorld(scene, PhysicsWorld);
}
insertCoin().then(() => {
setupGame();
})Step 2: Create the World with Floor and Walls
Create the game arena with a floor and walls that contain the cars within the play area.
import * as THREE from 'three';
import * as CANNON from 'cannon';
import shape2mesh from "./utils/shape2mesh";
export default function createWorld(scene, PhysicsWorld, width = 20){
const floor = new CANNON.Body({
mass: 0,
shape: new CANNON.Box(new CANNON.Vec3(width, width, 100)),
})
floor.position.set(0, 0, -100);
const floorMesh = shape2mesh(floor, new THREE.MeshPhongMaterial({ color: "gray" }));
PhysicsWorld.addBody(floor);
scene.add(floorMesh);
// create walls around the plane geometry
const wall1 = new CANNON.Body({
mass: 0,
shape: new CANNON.Box(new CANNON.Vec3(width, 0.1, 0.3)),
})
wall1.position.set(0, width, 0);
const wall1Mesh = shape2mesh(wall1, new THREE.MeshPhongMaterial({ color: "gray" }));
PhysicsWorld.addBody(wall1);
scene.add(wall1Mesh);
const wall2 = new CANNON.Body({
mass: 0,
shape: new CANNON.Box(new CANNON.Vec3(width, 0.1, 0.3)),
})
wall2.position.set(0, -width, 0);
const wall2Mesh = shape2mesh(wall2, new THREE.MeshPhongMaterial({ color: "gray" }));
PhysicsWorld.addBody(wall2);
scene.add(wall2Mesh);
const wall3 = new CANNON.Body({
mass: 0,
shape: new CANNON.Box(new CANNON.Vec3(0.1, width, 0.3)),
})
wall3.position.set(width, 0, 0);
const wall3Mesh = shape2mesh(wall3, new THREE.MeshPhongMaterial({ color: "gray" }));
PhysicsWorld.addBody(wall3);
scene.add(wall3Mesh);
const wall4 = new CANNON.Body({
mass: 0,
shape: new CANNON.Box(new CANNON.Vec3(0.1, width, 0.3)),
})
wall4.position.set(-width, 0, 0);
const wall4Mesh = shape2mesh(wall4, new THREE.MeshPhongMaterial({ color: "gray" }));
PhysicsWorld.addBody(wall4);
scene.add(wall4Mesh);
}Step 3: Create Utility Files for Time and Shape Conversion
Create utility classes for time management and converting physics bodies to visual meshes.
export default class Time {
constructor() {
this.tickCallbacks = [];
this.lastTime = performance.now();
this.delta = 0;
this.elapsed = 0;
this.loop = this.loop.bind(this);
requestAnimationFrame(this.loop);
}
loop() {
const now = performance.now();
this.delta = (now - this.lastTime) / 1000;
this.lastTime = now;
this.elapsed += this.delta;
this.tickCallbacks.forEach(callback => callback(this.delta, this.elapsed));
requestAnimationFrame(this.loop);
}
on(event, callback) {
if (event === 'tick') {
this.tickCallbacks.push(callback);
}
}
}import * as THREE from 'three';
import * as CANNON from 'cannon';
export default function shape2mesh(body, material) {
var obj = new THREE.Object3D();
for (var l = 0; l < body.shapes.length; l++) {
var shape = body.shapes[l];
var mesh;
switch (shape.type) {
case CANNON.Shape.types.SPHERE:
var sphere_geometry = new THREE.SphereGeometry(shape.radius);
mesh = new THREE.Mesh(sphere_geometry, material);
break;
case CANNON.Shape.types.PLANE:
var geometry = new THREE.PlaneGeometry(10, 10, 4, 4);
mesh = new THREE.Object3D();
var submesh = new THREE.Object3D();
var ground = new THREE.Mesh(geometry, material);
ground.scale.set(100, 100, 100);
submesh.add(ground);
ground.castShadow = true;
ground.receiveShadow = true;
mesh.add(submesh);
break;
case CANNON.Shape.types.BOX:
var box_geometry = new THREE.BoxGeometry(shape.halfExtents.x * 2,
shape.halfExtents.y * 2,
shape.halfExtents.z * 2);
mesh = new THREE.Mesh(box_geometry, material);
break;
default:
throw "Visual type not recognized: " + shape.type;
}
mesh.receiveShadow = true;
mesh.castShadow = true;
if (mesh.children) {
for (var i = 0; i < mesh.children.length; i++) {
mesh.children[i].castShadow = true;
mesh.children[i].receiveShadow = true;
}
}
var o = body.shapeOffsets[l];
var q = body.shapeOrientations[l];
mesh.position.set(o.x, o.y, o.z);
mesh.quaternion.set(q.x, q.y, q.z, q.w);
obj.add(mesh);
}
obj.position.copy(body.position);
return obj;
};Step 4: Create the Car Physics System
Create the physics engine for the car with realistic suspension, steering, and wheel physics using Cannon.js RaycastVehicle.
import * as CANNON from 'cannon';
import * as THREE from 'three'
export default class Physics {
constructor(_options) {
this.debug = _options.debug
this.time = _options.time
this.controls = _options.controls
if (this.debug) {
this.debugFolder = this.debug.addFolder('physics')
}
this.world = _options.world;
this.setWorld()
this.setModels()
this.setCar()
}
destroy(){
this.car.destroy()
}
setWorld() {
this.world.gravity.set(0, 0, - 3.25)
this.world.allowSleep = true
this.world.defaultContactMaterial.friction = 0
this.world.defaultContactMaterial.restitution = 0.2
}
setModels() {
this.models = {}
this.models.container = new THREE.Object3D()
this.models.container.visible = false
}
setCar() {
this.car = {}
this.car.steering = 0
this.car.accelerating = 0
this.car.speed = 0
this.car.worldForward = new CANNON.Vec3()
this.car.angle = 0
this.car.forwardSpeed = 0
this.car.oldPosition = new CANNON.Vec3()
this.car.goingForward = true
this.car.options = {}
this.car.options.chassisWidth = 1.02
this.car.options.chassisHeight = 1.16
this.car.options.chassisDepth = 2.03
this.car.options.chassisOffset = new CANNON.Vec3(0, 0, 0.41)
this.car.options.chassisMass = 20
this.car.options.wheelFrontOffsetDepth = 0.635
this.car.options.wheelBackOffsetDepth = - 0.475
this.car.options.wheelOffsetWidth = 0.39
this.car.options.wheelRadius = 0.25
this.car.options.wheelHeight = 0.24
this.car.options.wheelSuspensionStiffness = 25
this.car.options.wheelSuspensionRestLength = 0.1
this.car.options.wheelFrictionSlip = 5
this.car.options.wheelDampingRelaxation = 1.8
this.car.options.wheelDampingCompression = 1.5
this.car.options.wheelMaxSuspensionForce = 100000
this.car.options.wheelRollInfluence = 0.01
this.car.options.wheelMaxSuspensionTravel = 0.3
this.car.options.wheelCustomSlidingRotationalSpeed = - 30
this.car.options.wheelMass = 5
this.car.options.controlsSteeringSpeed = 0.005
this.car.options.controlsSteeringMax = Math.PI * 0.17
this.car.options.controlsSteeringQuad = false
this.car.options.controlsAcceleratinMaxSpeed = 0.055
this.car.options.controlsAcceleratinMaxSpeedBoost = 0.11
this.car.options.controlsAcceleratingSpeed = 2
this.car.options.controlsAcceleratingSpeedBoost = 3.5
this.car.options.controlsAcceleratingQuad = true
this.car.options.controlsBrakeStrength = 0.45
this.car.jump = (_toReturn = true, _strength = 60) => {
let worldPosition = this.car.chassis.body.position
worldPosition = worldPosition.vadd(new CANNON.Vec3(_toReturn ? 0.08 : 0, 0, 0))
this.car.chassis.body.applyImpulse(new CANNON.Vec3(0, 0, _strength), worldPosition)
}
this.car.create = () => {
this.car.chassis = {}
this.car.chassis.shape = new CANNON.Box(new CANNON.Vec3(this.car.options.chassisDepth * 0.5, this.car.options.chassisWidth * 0.5, this.car.options.chassisHeight * 0.5))
this.car.chassis.body = new CANNON.Body({ mass: this.car.options.chassisMass })
this.car.chassis.body.allowSleep = false
this.car.chassis.body.position.set(0, 0, 12)
this.car.chassis.body.sleep()
this.car.chassis.body.addShape(this.car.chassis.shape, this.car.options.chassisOffset)
this.car.chassis.body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 0, 1), - Math.PI * 0.5)
this.car.vehicle = new CANNON.RaycastVehicle({
chassisBody: this.car.chassis.body
})
this.car.wheels = {}
this.car.wheels.options = {
radius: this.car.options.wheelRadius,
height: this.car.options.wheelHeight,
suspensionStiffness: this.car.options.wheelSuspensionStiffness,
suspensionRestLength: this.car.options.wheelSuspensionRestLength,
frictionSlip: this.car.options.wheelFrictionSlip,
dampingRelaxation: this.car.options.wheelDampingRelaxation,
dampingCompression: this.car.options.wheelDampingCompression,
maxSuspensionForce: this.car.options.wheelMaxSuspensionForce,
rollInfluence: this.car.options.wheelRollInfluence,
maxSuspensionTravel: this.car.options.wheelMaxSuspensionTravel,
customSlidingRotationalSpeed: this.car.options.wheelCustomSlidingRotationalSpeed,
useCustomSlidingRotationalSpeed: true,
directionLocal: new CANNON.Vec3(0, 0, - 1),
axleLocal: new CANNON.Vec3(0, 1, 0),
chassisConnectionPointLocal: new CANNON.Vec3(1, 1, 0)
}
this.car.wheels.options.chassisConnectionPointLocal.set(this.car.options.wheelFrontOffsetDepth, this.car.options.wheelOffsetWidth, 0)
this.car.vehicle.addWheel(this.car.wheels.options)
this.car.wheels.options.chassisConnectionPointLocal.set(this.car.options.wheelFrontOffsetDepth, - this.car.options.wheelOffsetWidth, 0)
this.car.vehicle.addWheel(this.car.wheels.options)
this.car.wheels.options.chassisConnectionPointLocal.set(this.car.options.wheelBackOffsetDepth, this.car.options.wheelOffsetWidth, 0)
this.car.vehicle.addWheel(this.car.wheels.options)
this.car.wheels.options.chassisConnectionPointLocal.set(this.car.options.wheelBackOffsetDepth, - this.car.options.wheelOffsetWidth, 0)
this.car.vehicle.addWheel(this.car.wheels.options)
this.car.vehicle.addToWorld(this.world)
this.car.wheels.indexes = {}
this.car.wheels.indexes.frontLeft = 0
this.car.wheels.indexes.frontRight = 1
this.car.wheels.indexes.backLeft = 2
this.car.wheels.indexes.backRight = 3
this.car.wheels.bodies = []
for (const _wheelInfos of this.car.vehicle.wheelInfos) {
const shape = new CANNON.Cylinder(_wheelInfos.radius, _wheelInfos.radius, this.car.wheels.options.height, 20)
const body = new CANNON.Body({ mass: this.car.options.wheelMass })
const quaternion = new CANNON.Quaternion()
quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), Math.PI / 2)
body.type = CANNON.Body.KINEMATIC
body.addShape(shape, new CANNON.Vec3(), quaternion)
this.car.wheels.bodies.push(body)
}
this.car.model = {}
this.car.model.container = new THREE.Object3D()
this.models.container.add(this.car.model.container)
this.car.model.material = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true })
this.car.model.chassis = new THREE.Mesh(new THREE.BoxGeometry(this.car.options.chassisDepth, this.car.options.chassisWidth, this.car.options.chassisHeight), this.car.model.material)
this.car.model.container.add(this.car.model.chassis)
this.car.model.wheels = []
const wheelGeometry = new THREE.CylinderGeometry(this.car.options.wheelRadius, this.car.options.wheelRadius, this.car.options.wheelHeight, 8, 1)
for (let i = 0; i < 4; i++) {
const wheel = new THREE.Mesh(wheelGeometry, this.car.model.material)
this.car.model.container.add(wheel)
this.car.model.wheels.push(wheel)
}
}
this.car.destroy = () => {
this.car.vehicle.removeFromWorld(this.world)
this.models.container.remove(this.car.model.container)
}
this.world.addEventListener('postStep', () => {
let positionDelta = new CANNON.Vec3()
positionDelta = positionDelta.copy(this.car.chassis.body.position)
positionDelta = positionDelta.vsub(this.car.oldPosition)
this.car.oldPosition.copy(this.car.chassis.body.position)
this.car.speed = positionDelta.length()
const localForward = new CANNON.Vec3(1, 0, 0)
this.car.chassis.body.vectorToWorldFrame(localForward, this.car.worldForward)
this.car.angle = Math.atan2(this.car.worldForward.y, this.car.worldForward.x)
this.car.forwardSpeed = this.car.worldForward.dot(positionDelta)
this.car.goingForward = this.car.forwardSpeed > 0
for (let i = 0; i < this.car.vehicle.wheelInfos.length; i++) {
this.car.vehicle.updateWheelTransform(i)
const transform = this.car.vehicle.wheelInfos[i].worldTransform
this.car.wheels.bodies[i].position.copy(transform.position)
this.car.wheels.bodies[i].quaternion.copy(transform.quaternion)
if (i === 1 || i === 3) {
const rotationQuaternion = new CANNON.Quaternion(0, 0, 0, 1)
rotationQuaternion.setFromAxisAngle(new CANNON.Vec3(0, 0, 1), Math.PI)
this.car.wheels.bodies[i].quaternion = this.car.wheels.bodies[i].quaternion.mult(rotationQuaternion)
}
}
if (!this.controls.isJoystickPressed() && !this.controls.isPressed('down')) {
let slowDownForce = this.car.worldForward.clone()
if (this.car.goingForward) {
slowDownForce = slowDownForce.negate()
}
slowDownForce = slowDownForce.scale(this.car.chassis.body.velocity.length() * 0.1)
this.car.chassis.body.applyImpulse(slowDownForce, this.car.chassis.body.position)
}
})
this.time.on('tick', () => {
this.car.model.chassis.position.copy(this.car.chassis.body.position).add(this.car.options.chassisOffset)
this.car.model.chassis.quaternion.copy(this.car.chassis.body.quaternion)
for (const _wheelKey in this.car.wheels.bodies) {
const wheelBody = this.car.wheels.bodies[_wheelKey]
const wheelMesh = this.car.model.wheels[_wheelKey]
wheelMesh.position.copy(wheelBody.position)
wheelMesh.quaternion.copy(wheelBody.quaternion)
}
let deltaAngle = 0
if (this.controls.angle()) {
deltaAngle = (this.controls.angle() - this.car.angle + Math.PI) % (Math.PI * 2) - Math.PI
deltaAngle = deltaAngle < - Math.PI ? deltaAngle + Math.PI * 2 : deltaAngle
}
const goingForward = Math.abs(this.car.forwardSpeed) < 0.01 ? true : this.car.goingForward
this.car.steering = deltaAngle * (goingForward ? - 1 : 1)
if (Math.abs(this.car.steering) > this.car.options.controlsSteeringMax) {
this.car.steering = Math.sign(this.car.steering) * this.car.options.controlsSteeringMax
}
this.car.vehicle.setSteeringValue(- this.car.steering, this.car.wheels.indexes.frontLeft)
this.car.vehicle.setSteeringValue(- this.car.steering, this.car.wheels.indexes.frontRight)
const accelerationSpeed = this.controls.isPressed('boost') ? this.car.options.controlsAcceleratingSpeedBoost : this.car.options.controlsAcceleratingSpeed
const accelerateStrength = this.time.delta * accelerationSpeed
const controlsAcceleratinMaxSpeed = this.controls.isPressed('boost') ? this.car.options.controlsAcceleratinMaxSpeedBoost : this.car.options.controlsAcceleratinMaxSpeed
if (this.controls.isJoystickPressed() && !this.controls.isPressed('down')) {
if (this.car.speed < controlsAcceleratinMaxSpeed || !this.car.goingForward) {
this.car.accelerating = accelerateStrength
} else {
this.car.accelerating = 0
}
} else if (this.controls.isPressed('down')) {
if (this.car.speed < controlsAcceleratinMaxSpeed || this.car.goingForward) {
this.car.accelerating = - accelerateStrength
} else {
this.car.accelerating = 0
}
} else {
this.car.accelerating = 0
}
this.car.vehicle.applyEngineForce(- this.car.accelerating, this.car.wheels.indexes.backLeft)
this.car.vehicle.applyEngineForce(- this.car.accelerating, this.car.wheels.indexes.backRight)
})
this.car.create()
}
}Step 5: Create the Car Visual Component
Create the visual car component that syncs with the physics simulation and handles the 3D model rendering.
import * as THREE from 'three'
export default class Car {
constructor(_options) {
this.time = _options.time
this.physics = _options.physics
this.container = new THREE.Object3D()
this.position = new THREE.Vector3()
this.setMovement()
this.setChassis(_options.chassisObject)
this.setWheels(_options.wheelObject)
}
setMovement() {
this.movement = {}
this.movement.speed = new THREE.Vector3()
this.movement.localSpeed = new THREE.Vector3()
this.movement.acceleration = new THREE.Vector3()
this.movement.localAcceleration = new THREE.Vector3()
this.time.on('tick', () => {
const movementSpeed = new THREE.Vector3()
movementSpeed.copy(this.chassis.object.position).sub(this.chassis.oldPosition)
this.movement.acceleration = movementSpeed.clone().sub(this.movement.speed)
this.movement.speed.copy(movementSpeed)
this.movement.localSpeed = this.movement.speed.clone().applyAxisAngle(new THREE.Vector3(0, 0, 1), - this.chassis.object.rotation.z)
this.movement.localAcceleration = this.movement.acceleration.clone().applyAxisAngle(new THREE.Vector3(0, 0, 1), - this.chassis.object.rotation.z)
})
}
setChassis(object) {
this.chassis = {}
this.chassis.offset = new THREE.Vector3(0, 0, - 0.28)
this.chassis.object = object;
this.chassis.object.position.copy(this.physics.car.chassis.body.position)
this.chassis.oldPosition = this.chassis.object.position.clone()
this.container.add(this.chassis.object)
this.time.on('tick', () => {
this.chassis.oldPosition = this.chassis.object.position.clone()
this.chassis.object.position.copy(this.physics.car.chassis.body.position).add(this.chassis.offset)
this.chassis.object.quaternion.copy(this.physics.car.chassis.body.quaternion)
this.position.copy(this.chassis.object.position)
})
}
setWheels(object) {
this.wheels = {}
this.wheels.object = object;
this.wheels.items = []
for (let i = 0; i < 4; i++) {
const object = this.wheels.object.clone()
this.wheels.items.push(object)
this.container.add(object)
}
this.time.on('tick', () => {
for (const _wheelKey in this.physics.car.wheels.bodies) {
const wheelBody = this.physics.car.wheels.bodies[_wheelKey]
const wheelObject = this.wheels.items[_wheelKey]
wheelObject.position.copy(wheelBody.position)
wheelObject.quaternion.copy(wheelBody.quaternion)
}
})
}
}Step 6: Create the Car Loader
Create the car model loader that loads GLB models and applies player-specific colors.
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
async function loadModel(url) {
return new Promise((resolve, reject) => {
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('draco/')
dracoLoader.setDecoderConfig({ type: 'js' })
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader)
loader.load(url, resolve, undefined, reject)
});
}
function applyMaterial(mesh, primaryColor) {
const materials = {
'pureRed': new THREE.MeshBasicMaterial({ color: 0xff2800 }),
'pureWhite': new THREE.MeshBasicMaterial({ color: 0xfffffc }),
'pureBlack': new THREE.MeshBasicMaterial({ color: 0x160000 }),
'pureYellow': new THREE.MeshBasicMaterial({ color: 0xffe889 }),
'shadeWhite': new THREE.MeshPhongMaterial({ color: 0xfffffc }),
'shadeBlack': new THREE.MeshPhongMaterial({ color: 0x160000 }),
'shadeRed': new THREE.MeshPhongMaterial({ color: primaryColor || 0xff2800 }),
}
let materialName = Object.keys(materials).find((_materialName) => mesh.name.startsWith(_materialName));
if (typeof materialName === 'undefined') {
materialName = 'pureWhite';
}
mesh.material = materials[materialName].clone();
return mesh;
}
function processModel(obj, primaryColor) {
const container = new THREE.Object3D()
const center = new THREE.Vector3()
const baseChildren = [...obj.children]
for (const _child of baseChildren) {
if (_child.name.match(/^center_?[0-9]{0,3}?/i)) {
center.set(_child.position.x, _child.position.y, _child.position.z)
}
if (_child instanceof THREE.Mesh) {
const mesh = applyMaterial(_child, primaryColor);
mesh.receiveShadow = true;
mesh.castShadow = true;
container.add(mesh);
}
if (center.length() > 0) {
for (const _child of container.children) {
_child.position.sub(center)
}
container.position.add(center)
}
}
return container;
}
export default async function loadCar(primaryColor=0xff2800) {
const chassisModel = await loadModel('/carmodel/chassis.glb');
const wheelModel = await loadModel('/carmodel/wheel.glb');
const chassisObject = processModel(chassisModel.scene, primaryColor);
const wheelObject = processModel(wheelModel.scene);
return { chassisObject, wheelObject };
}Step 7: Create the Main Car Module
Create the main car module that combines physics and visual components with methods for multiplayer synchronization.
import * as THREE from 'three'
import Physics from './Physics.js'
import Car from './Car.js'
function roundOffPos(pos){
return {
x: Math.round(pos.x * 1000) / 1000,
y: Math.round(pos.y * 1000) / 1000,
z: Math.round(pos.z * 1000) / 1000
}
}
function roundOffQuat(quat){
return {
x: Math.round(quat.x * 1000) / 1000,
y: Math.round(quat.y * 1000) / 1000,
z: Math.round(quat.z * 1000) / 1000,
w: Math.round(quat.w * 1000) / 1000
}
}
export default class {
constructor({ initialPos, debug, time, physicsWorld, controls, chassisObject, wheelObject }) {
this.debug = debug
this.time = time
this.physicsWorld = physicsWorld
this.controls = controls
this.chassisObject = chassisObject
this.wheelObject = wheelObject
this.container = new THREE.Object3D()
this.container.matrixAutoUpdate = false
this.setPhysics()
this.setCar()
this.initCar(initialPos);
}
initCar(pos) {
this.physics.car.chassis.body.sleep()
this.physics.car.chassis.body.position.set(pos.x, pos.y, pos.z)
window.setTimeout(() => {
this.physics.car.chassis.body.wakeUp()
}, 300)
}
pos() {
return [
roundOffPos(this.physics.car.chassis.body.position),
roundOffPos(this.physics.car.wheels.bodies[0].position),
roundOffPos(this.physics.car.wheels.bodies[1].position),
roundOffPos(this.physics.car.wheels.bodies[2].position),
roundOffPos(this.physics.car.wheels.bodies[3].position),
];
}
setPos(posArray) {
this.physics.car.chassis.body.position.set(posArray[0].x, posArray[0].y, posArray[0].z);
this.physics.car.wheels.bodies[0].position.set(posArray[1].x, posArray[1].y, posArray[1].z);
this.physics.car.wheels.bodies[1].position.set(posArray[2].x, posArray[2].y, posArray[2].z);
this.physics.car.wheels.bodies[2].position.set(posArray[3].x, posArray[3].y, posArray[3].z);
this.physics.car.wheels.bodies[3].position.set(posArray[4].x, posArray[4].y, posArray[4].z);
}
quaternion() {
return [
roundOffQuat(this.physics.car.chassis.body.quaternion),
roundOffQuat(this.physics.car.wheels.bodies[0].quaternion),
roundOffQuat(this.physics.car.wheels.bodies[1].quaternion),
roundOffQuat(this.physics.car.wheels.bodies[2].quaternion),
roundOffQuat(this.physics.car.wheels.bodies[3].quaternion),
]
}
setQuaternion(quaternionArray) {
this.physics.car.chassis.body.quaternion.set(quaternionArray[0].x, quaternionArray[0].y, quaternionArray[0].z, quaternionArray[0].w);
this.physics.car.wheels.bodies[0].quaternion.set(quaternionArray[1].x, quaternionArray[1].y, quaternionArray[1].z, quaternionArray[1].w);
this.physics.car.wheels.bodies[1].quaternion.set(quaternionArray[2].x, quaternionArray[2].y, quaternionArray[2].z, quaternionArray[2].w);
this.physics.car.wheels.bodies[2].quaternion.set(quaternionArray[3].x, quaternionArray[3].y, quaternionArray[3].z, quaternionArray[3].w);
this.physics.car.wheels.bodies[3].quaternion.set(quaternionArray[4].x, quaternionArray[4].y, quaternionArray[4].z, quaternionArray[4].w);
}
setPhysics() {
this.physics = new Physics({
debug: this.debug,
time: this.time,
controls: this.controls,
world: this.physicsWorld,
})
this.container.add(this.physics.models.container)
}
setCar() {
this.car = new Car({
time: this.time,
physics: this.physics,
chassisObject: this.chassisObject,
wheelObject: this.wheelObject,
})
this.container.add(this.car.container)
}
destroy() {
this.physics.destroy();
}
}Step 8: Add Player Join Handler and Game Loop
Add the multiplayer player handling using onPlayerJoin and the game loop that synchronizes physics across all players. Use Joystick for controls, isHost for host-authoritative physics, setState to broadcast positions, and getState to receive remote positions.
// Handle players joining
let playersAndCars = [];
onPlayerJoin(async (player) => {
const color = player.getProfile().color.hex;
const { chassisObject, wheelObject } = await loadCar(color);
let controls = new Joystick(player, {
buttons: [
{id: "down", icon: mobileRevTriangle}
],
});
const car = new Car({
initialPos: new THREE.Vector3(Math.random() * 10, Math.random() * 10, 12),
time: time,
chassisObject: chassisObject,
wheelObject: wheelObject,
physicsWorld: PhysicsWorld,
controls: controls
});
scene.add(car.container);
player.onQuit(() => {
scene.remove(car.container);
car.destroy();
});
playersAndCars.push({ player, car });
});
// Add some spheres
const spherePos = getState('spherePos') || [
{ x: 0, y: 0, z: 40 },
{ x: 15, y: 10, z: 40 },
{ x: 0, y: -10, z: 30 },
{ x: -15, y: 10, z: 40 },
];
const sphereColors = [0xff3300, 0xff3300, 0xff3300, 0xff3300];
const sphereRadii = [1, 0.5, 1, 0.5];
const spheres = spherePos.map((pos, i) => {
return addSphere(
pos,
sphereColors[i],
sphereRadii[i],
10 * sphereRadii[i]);
});
spheres.forEach(({ mesh, body }) => {
scene.add(mesh);
PhysicsWorld.addBody(body);
});
// Main loop
time.on('tick', (delta) => {
renderer.render(scene, camera);
PhysicsWorld.step(1 / 60, delta, 3);
// On host device, update all player and spheres pos
if (isHost()) {
playersAndCars.forEach(({ player, car }) => {
player.setState('pos', car.pos());
player.setState('quaternion', car.quaternion());
});
spheres.forEach(({ mesh, body }) => {
mesh.position.copy(body.position);
});
setState('spherePos', spheres.map(({ mesh }) => mesh.position));
}
// On client, get everyone's pos and update locally
else {
playersAndCars.forEach(({ player, car }) => {
const pos = player.getState('pos');
if (pos) {
car.setPos(pos);
}
const quaternion = player.getState('quaternion');
if (quaternion) {
car.setQuaternion(quaternion);
}
});
const spherePos = getState('spherePos');
if (spherePos) {
spheres.forEach(({ mesh }, i) => {
mesh.position.copy(spherePos[i]);
});
}
}
// Follow my car with camera
const pos = myPlayer().getState('pos') ? myPlayer().getState('pos')[0] : null;
if (pos) {
camera.position.copy(pos);
camera.position.add(cameraCoords);
}
});Step 9: Add Helper Function for Spheres
Add a helper function to create interactive sphere objects that cars can push around in the arena.
function addSphere(pos = { x: 0, y: 20, z: 0 }, color = 0xF9F9F9, radius = 1, mass = 1) {
const sphereShape = new CANNON.Sphere(radius);
const body = new CANNON.Body({ mass: mass, shape: sphereShape });
body.position.set(pos.x, pos.y, pos.z);
body.linearDamping = 0.6;
const material = new THREE.MeshLambertMaterial({ color: color, shading: THREE.FlatShading });
const mesh = shape2mesh(body, material);
return { mesh, body };
}Step 10: Style and Camera Setup
Style the application and set up the isometric camera angle for the game view.
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #b4e0f1;
}
canvas {
display: block;
}Improvements
- Add collision detection between cars for more competitive gameplay
- Implement lap timing and checkpoint system for racing modes
- Add boost pads or speed power-ups on the arena
- Create different car models or customization options
- Add particle effects for collisions and car exhaust
- Implement a spectate mode for players who disconnect