Build browser-based WebGL/WebGPU games with PlayCanvas engine. Create entities, scripts, physics, audio, and deploy web games. Use when building browser games, 3D web apps, WebGL games, WebGPU games, or when user mentions PlayCanvas, game engine, 3D graphics, entity component system, browser game, web game, interactive 3D.
Build high-performance WebGL and WebGPU browser games and interactive 3D applications using the PlayCanvas engine.
| Property | Value |
|---|---|
| Engine Version | 2.14.4 (Latest Stable) |
| License | MIT |
| Official Docs | https://developer.playcanvas.com/ |
| API Reference | https://api.playcanvas.com/ |
| GitHub |
| https://github.com/playcanvas/engine |
| NPM Package | https://www.npmjs.com/package/playcanvas |
mkdir my-playcanvas-game && cd my-playcanvas-game
pnpm init
pnpm add playcanvas
pnpm add -D vite typescript @types/node
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlayCanvas Game</title>
<style>
* { margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#application-canvas { width: 100%; height: 100%; display: block; }
</style>
</head>
<body>
<canvas id="application-canvas"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
import * as pc from 'playcanvas';
const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
const app = new pc.Application(canvas, {
mouse: new pc.Mouse(canvas),
keyboard: new pc.Keyboard(window),
});
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.start();
// Create camera
const camera = new pc.Entity('Camera');
camera.addComponent('camera', { clearColor: new pc.Color(0.1, 0.1, 0.15) });
camera.setPosition(0, 2, 5);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);
// Create light
const light = new pc.Entity('Light');
light.addComponent('light', { type: 'directional' });
light.setEulerAngles(45, 30, 0);
app.root.addChild(light);
// Create rotating box
const box = new pc.Entity('Box');
box.addComponent('render', { type: 'box' });
app.root.addChild(box);
// Rotate box every frame
app.on('update', (dt) => {
box.rotate(0, 30 * dt, 0);
});
console.log(`PlayCanvas v${pc.version} initialized`);
npx vite
Open http://localhost:5173 to see a rotating cube.
PlayCanvas is an open-source 3D game engine that runs entirely in the browser using WebGL2 and WebGPU. Key capabilities include:
Before starting a PlayCanvas project, gather the following requirements:
my-game/
├── assets/
│ ├── models/ # glTF/GLB 3D models
│ ├── textures/ # Image textures
│ ├── audio/ # Sound files
│ └── fonts/ # Font files
├── src/
│ ├── main.ts # Application entry point
│ ├── scene.ts # Scene setup
│ ├── scripts/ # PlayCanvas script components
│ │ ├── player.ts
│ │ ├── enemy.ts
│ │ └── index.ts
│ ├── systems/ # Game systems (physics, AI, etc.)
│ └── utils/ # Helper functions
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md
{
"name": "my-playcanvas-game",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"playcanvas": "^2.14.4"
},
"devDependencies": {
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"]
}
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
publicDir: 'assets',
build: {
outDir: 'dist',
sourcemap: true,
},
server: {
port: 5173,
open: true,
},
});
import * as pc from 'playcanvas';
// Create application with input devices
const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
const app = new pc.Application(canvas, {
mouse: new pc.Mouse(canvas),
touch: 'ontouchstart' in window ? new pc.TouchDevice(canvas) : undefined,
keyboard: new pc.Keyboard(window),
gamepads: new pc.GamePads(),
graphicsDeviceOptions: {
preferWebGpu: true, // Use WebGPU if available
},
});
// Configure canvas sizing
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
// Handle window resize
window.addEventListener('resize', () => app.resizeCanvas());
// Start the application
app.start();
function createScene(app: pc.Application): void {
// Camera
const camera = new pc.Entity('MainCamera');
camera.addComponent('camera', {
clearColor: new pc.Color(0.1, 0.1, 0.15),
fov: 60,
nearClip: 0.1,
farClip: 1000,
});
camera.setPosition(0, 5, 10);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);
// Directional light (sun)
const sun = new pc.Entity('Sun');
sun.addComponent('light', {
type: 'directional',
color: new pc.Color(1, 0.95, 0.9),
intensity: 1.2,
castShadows: true,
shadowBias: 0.2,
shadowDistance: 50,
shadowResolution: 2048,
});
sun.setEulerAngles(45, 30, 0);
app.root.addChild(sun);
// Ambient light
app.scene.ambientLight = new pc.Color(0.2, 0.2, 0.25);
// Enable shadows
app.scene.rendering.shadowsEnabled = true;
}
// Create an entity
const entity = new pc.Entity('MyEntity');
// Add to scene hierarchy
app.root.addChild(entity);
// Transform operations
entity.setPosition(0, 1, 0);
entity.setLocalScale(2, 2, 2);
entity.setEulerAngles(0, 45, 0);
entity.rotate(0, 10, 0);
entity.translate(1, 0, 0);
// Find entities
const found = app.root.findByName('MyEntity');
const tagged = app.root.findByTag('enemy');
// Render component (visual mesh)
entity.addComponent('render', {
type: 'box', // box, sphere, cylinder, capsule, cone, plane
material: myMaterial,
castShadows: true,
receiveShadows: true,
});
// Camera component
entity.addComponent('camera', {
clearColor: new pc.Color(0, 0, 0),
fov: 60,
nearClip: 0.1,
farClip: 1000,
priority: 0,
});
// Light component
entity.addComponent('light', {
type: 'point', // directional, point, spot
color: new pc.Color(1, 1, 1),
intensity: 1,
range: 10,
castShadows: false,
});
// Script component
entity.addComponent('script');
entity.script.create('myScript', {
attributes: { speed: 10 },
});
import * as pc from 'playcanvas';
export class PlayerController extends pc.ScriptType {
static override scriptName = 'playerController';
// Declare attributes
speed: number = 5;
jumpForce: number = 10;
static override attributes = {
speed: { type: 'number', default: 5, title: 'Move Speed' },
jumpForce: { type: 'number', default: 10, title: 'Jump Force' },
};
// Private properties
private _velocity: pc.Vec3 = new pc.Vec3();
// Lifecycle methods
initialize(): void {
// Called once when script starts
console.log('Player initialized');
}
update(dt: number): void {
// Called every frame
this.handleInput(dt);
}
postUpdate(dt: number): void {
// Called after all updates
}
swap(old: PlayerController): void {
// Called on hot-reload, preserve state
this._velocity.copy(old._velocity);
}
// Custom methods
private handleInput(dt: number): void {
const keyboard = this.app.keyboard;
if (!keyboard) return;
this._velocity.set(0, 0, 0);
if (keyboard.isPressed(pc.KEY_W)) this._velocity.z -= 1;
if (keyboard.isPressed(pc.KEY_S)) this._velocity.z += 1;
if (keyboard.isPressed(pc.KEY_A)) this._velocity.x -= 1;
if (keyboard.isPressed(pc.KEY_D)) this._velocity.x += 1;
if (this._velocity.lengthSq() > 0) {
this._velocity.normalize().mulScalar(this.speed * dt);
this.entity.translate(this._velocity);
}
}
}
// In main.ts
import { PlayerController } from './scripts/player-controller';
app.scripts.add(PlayerController);
// Attach to entity
const player = new pc.Entity('Player');
player.addComponent('script');
player.script!.create('playerController', {
attributes: { speed: 8, jumpForce: 12 },
});
Physics requires ammo.js. For engine-standalone projects:
// Load ammo.js (choose one method)
// Method 1: CDN (add to index.html before your script)
// <script src="https://cdn.jsdelivr.net/npm/[email protected]/ammo.wasm.js"></script>
// Method 2: NPM package
import Ammo from 'ammo.js';
async function initPhysics(app: pc.Application): Promise<void> {
// Wait for Ammo to load
await new Promise<void>((resolve) => {
if (typeof Ammo === 'function') {
Ammo().then(() => resolve());
} else {
resolve();
}
});
// Set gravity
app.systems.rigidbody?.setGravity(0, -9.81, 0);
}
// Static body (doesn't move)
const ground = new pc.Entity('Ground');
ground.addComponent('render', { type: 'box' });
ground.addComponent('rigidbody', {
type: 'static',
friction: 0.5,
restitution: 0.3,
});
ground.addComponent('collision', {
type: 'box',
halfExtents: new pc.Vec3(10, 0.5, 10),
});
ground.setLocalScale(20, 1, 20);
// Dynamic body (affected by physics)
const ball = new pc.Entity('Ball');
ball.addComponent('render', { type: 'sphere' });
ball.addComponent('rigidbody', {
type: 'dynamic',
mass: 1,
friction: 0.5,
restitution: 0.8,
linearDamping: 0.1,
angularDamping: 0.1,
});
ball.addComponent('collision', {
type: 'sphere',
radius: 0.5,
});
ball.setPosition(0, 5, 0);
// Kinematic body (controlled by code, affects others)
const platform = new pc.Entity('Platform');
platform.addComponent('rigidbody', { type: 'kinematic' });
platform.addComponent('collision', { type: 'box' });
class CollisionHandler extends pc.ScriptType {
static override scriptName = 'collisionHandler';
initialize(): void {
const collision = this.entity.collision;
if (!collision) return;
collision.on('collisionstart', this.onCollisionStart, this);
collision.on('collisionend', this.onCollisionEnd, this);
collision.on('triggerenter', this.onTriggerEnter, this);
collision.on('triggerleave', this.onTriggerLeave, this);
}
onCollisionStart(result: pc.ContactResult): void {
console.log('Collision with:', result.other.name);
// Access contact points
for (const contact of result.contacts) {
console.log('Contact point:', contact.point);
console.log('Contact normal:', contact.normal);
}
}
onCollisionEnd(other: pc.Entity): void {
console.log('Collision ended with:', other.name);
}
onTriggerEnter(other: pc.Entity): void {
console.log('Trigger entered by:', other.name);
}
onTriggerLeave(other: pc.Entity): void {
console.log('Trigger left by:', other.name);
}
}
// Create audio listener (usually on camera)
const camera = new pc.Entity('Camera');
camera.addComponent('camera');
camera.addComponent('audiolistener');
// Create sound source
const audioSource = new pc.Entity('AudioSource');
audioSource.addComponent('sound', {
positional: true,
distanceModel: 'inverse',
refDistance: 1,
maxDistance: 100,
rollOffFactor: 1,
volume: 1,
});
class AudioManager extends pc.ScriptType {
static override scriptName = 'audioManager';
initialize(): void {
// Load audio asset
this.app.assets.loadFromUrl('audio/explosion.mp3', 'audio', (err, asset) => {
if (err) {
console.error('Failed to load audio:', err);
return;
}
// Add sound slot
this.entity.sound?.addSlot('explosion', {
asset: asset,
loop: false,
volume: 0.8,
pitch: 1,
autoPlay: false,
});
});
}
playSound(slotName: string): void {
this.entity.sound?.play(slotName);
}
stopSound(slotName: string): void {
this.entity.sound?.stop(slotName);
}
}
class KeyboardController extends pc.ScriptType {
static override scriptName = 'keyboardController';
update(dt: number): void {
const keyboard = this.app.keyboard;
if (!keyboard) return;
// Check if key is currently held
if (keyboard.isPressed(pc.KEY_SPACE)) {
console.log('Space is held');
}
// Check if key was just pressed this frame
if (keyboard.wasPressed(pc.KEY_E)) {
console.log('E was just pressed');
}
// Check if key was just released
if (keyboard.wasReleased(pc.KEY_Q)) {
console.log('Q was just released');
}
}
initialize(): void {
// Event-based input
this.app.keyboard?.on(pc.EVENT_KEYDOWN, this.onKeyDown, this);
this.app.keyboard?.on(pc.EVENT_KEYUP, this.onKeyUp, this);
}
onKeyDown(event: pc.KeyboardEvent): void {
if (event.key === pc.KEY_ESCAPE) {
console.log('Escape pressed');
}
}
onKeyUp(event: pc.KeyboardEvent): void {
// Handle key release
}
}
class MouseController extends pc.ScriptType {
static override scriptName = 'mouseController';
initialize(): void {
const mouse = this.app.mouse;
if (!mouse) return;
mouse.on(pc.EVENT_MOUSEDOWN, this.onMouseDown, this);
mouse.on(pc.EVENT_MOUSEUP, this.onMouseUp, this);
mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
mouse.on(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this);
// Disable right-click context menu
mouse.disableContextMenu();
}
onMouseDown(event: pc.MouseEvent): void {
if (event.button === pc.MOUSEBUTTON_LEFT) {
console.log('Left click at', event.x, event.y);
} else if (event.button === pc.MOUSEBUTTON_RIGHT) {
console.log('Right click');
} else if (event.button === pc.MOUSEBUTTON_MIDDLE) {
console.log('Middle click');
}
}
onMouseUp(event: pc.MouseEvent): void {
// Handle mouse up
}
onMouseMove(event: pc.MouseEvent): void {
// event.dx, event.dy for delta movement
console.log('Mouse delta:', event.dx, event.dy);
}
onMouseWheel(event: pc.MouseEvent): void {
console.log('Wheel delta:', event.wheelDelta);
}
}
class TouchController extends pc.ScriptType {
static override scriptName = 'touchController';
initialize(): void {
const touch = this.app.touch;
if (!touch) return;
touch.on(pc.EVENT_TOUCHSTART, this.onTouchStart, this);
touch.on(pc.EVENT_TOUCHMOVE, this.onTouchMove, this);
touch.on(pc.EVENT_TOUCHEND, this.onTouchEnd, this);
}
onTouchStart(event: pc.TouchEvent): void {
for (const touchPoint of event.touches) {
console.log('Touch start:', touchPoint.id, touchPoint.x, touchPoint.y);
}
}
onTouchMove(event: pc.TouchEvent): void {
for (const touchPoint of event.touches) {
console.log('Touch move:', touchPoint.id, touchPoint.x, touchPoint.y);
}
}
onTouchEnd(event: pc.TouchEvent): void {
for (const touchPoint of event.changedTouches) {
console.log('Touch end:', touchPoint.id);
}
}
}
async function loadModel(app: pc.Application, url: string): Promise<pc.Asset> {
return new Promise((resolve, reject) => {
app.assets.loadFromUrl(url, 'container', (err, asset) => {
if (err) {
reject(err);
return;
}
resolve(asset!);
});
});
}
// Usage
const modelAsset = await loadModel(app, 'models/character.glb');
const modelEntity = modelAsset.resource.instantiateRenderEntity();
app.root.addChild(modelEntity);
async function loadTexture(app: pc.Application, url: string): Promise<pc.Asset> {
return new Promise((resolve, reject) => {
app.assets.loadFromUrl(url, 'texture', (err, asset) => {
if (err) {
reject(err);
return;
}
resolve(asset!);
});
});
}
// Apply texture to material
const textureAsset = await loadTexture(app, 'textures/diffuse.png');
const material = new pc.StandardMaterial();
material.diffuseMap = textureAsset.resource;
material.update();
const material = new pc.StandardMaterial();
// Diffuse
material.diffuse = new pc.Color(0.8, 0.2, 0.2);
material.diffuseMap = diffuseTexture;
// Specular
material.specular = new pc.Color(1, 1, 1);
material.gloss = 0.8;
material.glossMap = glossTexture;
// Normal mapping
material.normalMap = normalTexture;
material.bumpiness = 1;
// Emission
material.emissive = new pc.Color(0, 0, 0);
material.emissiveIntensity = 1;
// Opacity
material.opacity = 1;
material.blendType = pc.BLEND_NONE; // BLEND_NORMAL for transparency
// Update material after changes
material.update();
class VRController extends pc.ScriptType {
static override scriptName = 'vrController';
initialize(): void {
// Check VR availability
if (this.app.xr.isAvailable(pc.XRTYPE_VR)) {
console.log('VR is available');
this.setupVRButton();
}
}
setupVRButton(): void {
const button = document.createElement('button');
button.textContent = 'Enter VR';
button.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:1000;';
document.body.appendChild(button);
button.addEventListener('click', () => this.enterVR());
}
enterVR(): void {
const camera = this.entity.camera;
if (!camera) return;
camera.startXr(pc.XRTYPE_VR, pc.XRSPACE_LOCALFLOOR, {
callback: (err) => {
if (err) {
console.error('Failed to start VR:', err);
} else {
console.log('VR session started');
}
},
});
}
}
PlayCanvas applications can be tested using standard web testing tools:
// src/utils/test-helpers.ts
export function createTestApp(): pc.Application {
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const app = new pc.Application(canvas);
app.start();
return app;
}
export function destroyTestApp(app: pc.Application): void {
app.destroy();
}
class PerformanceMonitor extends pc.ScriptType {
static override scriptName = 'performanceMonitor';
private _frameCount: number = 0;
private _elapsedTime: number = 0;
private _fps: number = 0;
update(dt: number): void {
this._frameCount++;
this._elapsedTime += dt;
if (this._elapsedTime >= 1) {
this._fps = this._frameCount / this._elapsedTime;
console.log(`FPS: ${this._fps.toFixed(1)}`);
// Log draw calls and triangles
const stats = this.app.stats;
console.log(`Draw calls: ${stats.drawCalls}`);
console.log(`Triangles: ${stats.frame.triangles}`);
this._frameCount = 0;
this._elapsedTime = 0;
}
}
}
# Build optimized production bundle
pnpm build
# Preview production build locally
pnpm preview
PlayCanvas applications are static content and can be hosted on any web server:
dist/ folder to any static host (Netlify, Vercel, GitHub Pages)# .github/workflows/deploy.yml