Without React

Learn how to build games using @pmndrs/viverse with vanilla Three.js.

@pmndrs/viverse offers everything from @react-three/viverse (excluding the hooks) in case you don't want to use react.

The following tutorial shows how to build a simple game and display a player tag from the Simple Game and Access Avatar and Profile tutorials using only Three.js.

Setting up the project

First, create a new project and install the required dependencies:

npm init -y
npm install three @pmndrs/viverse @pmndrs/uikit @viverse/sdk
npm install -D vite

Create an index.html file:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>VIVERSE Vanilla Game</title>
    <script async type="module" src="./index.ts"></script>
</head>
<body style="touch-action: none; margin: 0; position: relative; width: 100dvw; height: 100dvh; overflow: hidden;">
    <canvas id="root" style="position: absolute; inset: 0;"></canvas>
</body>
</html>

Step 1: Basic Three.js Setup

Start with the standard Three.js boilerplate in index.ts:

import {
  PerspectiveCamera,
  Scene,
  WebGLRenderer,
  Clock,
} from 'three'

// Create camera, scene, and renderer
const camera = new PerspectiveCamera(90)
camera.position.z = 1
camera.position.y = 1

const scene = new Scene()

const canvas = document.getElementById('root') as HTMLCanvasElement
const renderer = new WebGLRenderer({ 
  antialias: true, 
  canvas, 
  powerPreference: 'high-performance', 
  alpha: true 
})

const clock = new Clock()
clock.start()

// Basic render loop
renderer.setAnimationLoop(() => {
  const delta = clock.getDelta()
  renderer.render(scene, camera)
})

// Handle window resizing
function updateSize() {
  renderer.setSize(window.innerWidth, window.innerHeight)
  renderer.setPixelRatio(window.devicePixelRatio)
  camera.aspect = window.innerWidth / window.innerHeight
  camera.updateProjectionMatrix()
}

updateSize()
window.addEventListener('resize', updateSize)

Step 2: Adding the Sky

Add a realistic sky background using the Three.js Sky class:

import { MathUtils, Vector3 } from 'three'
import { Sky } from 'three/examples/jsm/Addons.js'

// Add after scene creation
const sky = new Sky()
sky.scale.setScalar(450000)
const phi = MathUtils.degToRad(40)
const theta = MathUtils.degToRad(30)
const sunPosition = new Vector3().setFromSphericalCoords(1, phi, theta)

sky.material.uniforms.sunPosition.value = sunPosition
scene.add(sky)

Step 3: Loading the Map

Load a 3D environment using GLTFLoader:

import { GLTFLoader } from 'three/examples/jsm/Addons.js'

// Add after sky setup
const gltfLoader = new GLTFLoader()
const ground = await gltfLoader.loadAsync('./map.glb')
ground.scene.traverse((object) => {
  object.castShadow = true
  object.receiveShadow = true
})
scene.add(ground.scene)

Make sure to place your map.glb file in the public folder.

Step 4: Adding Lighting

Create proper lighting with shadows:

import { AmbientLight, DirectionalLight } from 'three'

// Add after sky setup
const dirLight = new DirectionalLight('white', 1.0)
dirLight.shadow.bias = -0.001
dirLight.shadow.mapSize.set(1024, 1024)
dirLight.shadow.camera.left = -100
dirLight.shadow.camera.right = 100
dirLight.shadow.camera.top = 100
dirLight.shadow.camera.bottom = -50
dirLight.castShadow = true
dirLight.position.set(10, 10, 10)
scene.add(dirLight)
scene.add(new AmbientLight('white', 0.3))

// Enable shadows in renderer
renderer.shadowMap.enabled = true

Step 5: Adding Physics and the Character

Integrate physics and a controllable character:

import { BvhPhysicsWorld, SimpleCharacter } from '@pmndrs/viverse'

// Add after loading the map
const world = new BvhPhysicsWorld()
world.addFixedBody(ground.scene)
const character = new SimpleCharacter(camera, world, canvas, { 
  model: { url: undefined } // We'll add avatar URL later
})
scene.add(character)

// Update character in render loop
renderer.setAnimationLoop(() => {
  const delta = clock.getDelta()
  character.update(delta)
  renderer.render(scene, camera)
  
  // Respawn player when they fall down
  if (character.position.y < -10) {
    character.position.set(0, 0, 0)
  }
})

Step 6: VIVERSE Client Integration

Connect to VIVERSE services to access user profiles and avatars:

Note

The following assumes your are building your app with vite. If you are using a different build tool, you need to manually insure that the VIVERSE app ID is provided only in production builds.

import { Client } from '@viverse/sdk'
import AvatarClient from '@viverse/sdk/avatar-client'

// Add before character setup
const client = import.meta.env.VITE_VIVERSE_APP_ID == null
  ? undefined
  : new Client({ 
      clientId: import.meta.env.VITE_VIVERSE_APP_ID, 
      domain: 'https://account.htcvive.com/' 
    })

const auth = await client?.checkAuth()
const avatarClient = auth == null 
  ? undefined 
  : new AvatarClient({ 
      token: auth?.access_token, 
      baseURL: 'https://sdk-api.viverse.com/' 
    })

const profile = (await avatarClient?.getProfile()) ?? {
  name: 'Anonymous',
  activeAvatar: { headIconUrl: 'https://picsum.photos/200', vrmUrl: undefined },
}

// Update character creation to use profile avatar
const character = new SimpleCharacter(camera, world, canvas, { 
  model: { url: profile.activeAvatar?.vrmUrl, type: "vrm" } 
})

Create a .env.production file for your VIVERSE app ID:

VITE_VIVERSE_APP_ID=your_viverse_app_id_here

Step 7: Player Tag UI

Finally, add a floating player tag above the character:

import { Group } from 'three'
import { Container, withOpacity, Image, Text, reversePainterSortStable } from '@pmndrs/uikit'

// Configure renderer for UI rendering
renderer.setTransparentSort(reversePainterSortStable)
renderer.localClippingEnabled = true

// Create player tag
const playerTag = new Group()
character.add(playerTag)
playerTag.position.y = 2.15

const container = new Container({
  depthTest: false,
  renderOrder: 1,
  '*': {
    depthTest: false,
    renderOrder: 1,
  },
  borderRadius: 10,
  paddingX: 2,
  height: 20,
  backgroundColor: withOpacity('white', 0.5),
  flexDirection: 'row',
  alignItems: 'center',
  gap: 4,
})
playerTag.add(container)

const playerImage = new Image({ 
  width: 16, 
  height: 16, 
  borderRadius: 8, 
  src: profile.activeAvatar?.headIconUrl 
})
container.add(playerImage)

const playerText = new Text({ 
  fontWeight: 'bold', 
  fontSize: 12, 
  marginRight: 3, 
  text: profile.name 
})
container.add(playerText)

// Update render loop to include UI updates
renderer.setAnimationLoop(() => {
  const delta = clock.getDelta()
  character.update(delta)
  renderer.render(scene, camera)
  
  // Update the UI
  container.update(delta)
  // Rotate the player tag to face the camera
  playerTag.quaternion.copy(camera.quaternion)
  
  // Respawn player when they fall down
  if (character.position.y < -10) {
    character.position.set(0, 0, 0)
  }
})

Running the Project

Add a dev script to your package.json:

{
  "scripts": {
    "dev": "vite --host"
  }
}

Run your project:

npm run dev

Your vanilla Three.js game with VIVERSE integration is now complete! You should see a 3D environment with a controllable character that displays a floating player tag with the user's avatar and name.