Scripting (JavaScript)
Captic scenes are built on A-Frame's Entity-Component-System: every object in a world is an entity, and behaviour is added by attaching components to it. A script is one such component. You write it as a class that extends CapElement; Captic registers it as an A-Frame component and attaches it to the script's entity.
CapElement adds a few things on top of a plain A-Frame component: an optional Lit render() template for the entity's children, reactive properties, and helpers for materials, multiplayer games and translations. This page describes the class, its lifecycle and those helpers. The 3D UI elements you can use in a template (<cap-uix-button>, <cap-uix-text>, ...) are listed in UI/UX Components.
Adding a script
In the Studio, open Add Component and choose Script. The editor starts with a template class. When you save, the code is compiled as TypeScript (so type annotations and decorators work) and loaded as an ES module.
Rules every script must follow:
- Declare one class that extends
CapElementand default-export it:export default MyComponent_a1b2c3d4;. The component is registered under the kebab-cased class name (MyComponent_a1b2c3d4becomesmy-component-a1b2c3d4), so give each script a unique class name. - Do not call
AFRAME.registerComponentyourself. this.initializedis read-only. Use your own field, such asthis.isInitialized = true.- Schema types are lower-case strings (
type: 'number'), not constructors (Number).
These globals are available without an import: CapElement, html (Lit), the decorators property and query, THREE and AFRAME.
Class structure
class SpinningBox_8f2a1b9c extends CapElement {
// Schema: values set on the component, read with this.data
static schema = {
speed: { type: 'number', default: 1 },
label: { type: 'string', default: 'Click me' },
}
// Reactive property: setting it re-renders the template
@property() color = '#00e5ff'
init() {
// Called once when the script is attached. this.el is the entity,
// this.el.sceneEl the <a-scene>.
this.isInitialized = true
}
tick(time, timeDelta) {
// Called every frame. timeDelta is in milliseconds.
this.el.object3D.rotation.y += (timeDelta / 1000) * this.data.speed
}
render() {
return html`
<cap-box-rounded
class="interactable"
width="0.5" height="0.5" depth="0.5" radius="0.05"
color="${this.color}" opacity="1" transparent="false"
@click=${() => (this.color = this.color === '#00e5ff' ? '#ff0055' : '#00e5ff')}
></cap-box-rounded>
<cap-uix-text value="${this.data.label}" align="center" font-size="0.08" position="0 0.45 0"></cap-uix-text>
`
}
remove() {
// Clean up timers, listeners and anything you added outside this.el
}
}
export default SpinningBox_8f2a1b9c
Lifecycle methods
All methods are optional.
| Method | When it runs |
|---|---|
init(data) | Once, shortly after the script is attached. May be async (to await a model, a material or joinGame()): the template is rendered right away and again when init() finishes. |
update(oldData) | When the component's data changes. A-Frame also calls it once when the component is attached, which is before init() has run. render() runs after every update(). |
render() | Returns a Lit html template that is rendered into this.el. Call this.requestUpdate() to render again yourself. |
tick(time, timeDelta) | Every frame, once init() has run. |
tock(time, timeDelta, camera) | Every frame, after the scene has rendered. |
pause() / play() | When the entity or scene pauses or resumes. |
onLanguage(lang, locale) | When the visitor switches language (see Translations). |
remove() | When the script is removed or reloaded. |
Because update() and render() can run before init(), don't assume in them that init() has already built its state (check it first, e.g. if (!this.isInitialized) return).
Errors thrown in these methods are caught and logged to the browser console once per distinct error; the script keeps running (see Debugging).
The usual A-Frame component members are available as read-only getters: this.el, this.data, this.attrName, this.id, this.initialized, this.name, this.system and this.events. this.events is A-Frame's listener map: do not store your own values in it.
Schema and reactive properties
static schema works like an A-Frame component schema. Read the values from this.data; when they change, update(oldData) and render() run.
@property() declares a reactive field. Its value is stored in the component's data as JSON, so it must be JSON-serialisable (strings, numbers, booleans, arrays, plain objects). Assigning a new value re-renders the template. Mutating an array or object in place does not: assign a new one.
@query
@query(selector) turns a field into a getter that returns the first element in the whole scene that matches the selector (this.el.sceneEl.querySelector(selector)). Pass true as the second argument to cache the result. Use a unique id in the selector, or call this.el.querySelector(...) to search only inside your own entity.
@query('#player-rig')
playerRig
Clicks and pointer events
The cursor, VR controllers and hand tracking only hit elements with one of the classes interactable, intersectable, collidable, ui, uihover or navmesh. A common A-Frame class such as clickable gets no events.
- Put
class="interactable"on the entity the user points at, andclass="ui"on<cap-uix-*>elements. - Pointer events (
click,mousedown,mouseenter,mouseleave) do not bubble. A listener onthis.eldoes not see clicks on child elements: bind the handler on the child, e.g.@click=${...}in the template. - One press sends
mousedown(press) andclick(release). Listen forclickonly, unless you need both and can tell them apart. - Children you add with
appendChildare removed on the next render. Add them withthis.el.insertBefore(child, this.el.firstChild)or put them in the template.
Example: 2D dialog
A script can also render regular HTML. This example opens a modal <dialog> when the box is clicked.
class SampleElement extends CapElement {
@query('dialog')
dialogElement
#showInfo = () => {
this.dialogElement.showModal()
}
#closeDialog = () => {
this.dialogElement.close()
}
render() {
return html`
<style>
dialog {
z-index: 10;
margin-top: 100px;
background-color: hsl(0deg 0% 100% / 75%);
border: none;
border-radius: 0.5rem;
backdrop-filter: blur(15px);
width: 400px;
height: 50vh;
}
</style>
<dialog
@click="${(e) => {
// Close when the click lands outside the dialog box
const dialogDimensions = this.dialogElement.getBoundingClientRect()
if (
e.clientX < dialogDimensions.left ||
e.clientX > dialogDimensions.right ||
e.clientY < dialogDimensions.top ||
e.clientY > dialogDimensions.bottom
) {
this.#closeDialog()
}
}}"
>
<div>
<img
style="display:flex;height:300px;"
src="https://aomediacodec.github.io/av1-avif/testFiles/Link-U/kimono.avif"
/>
<h1>Hello from a script</h1>
<button @click="${this.#closeDialog}">Close</button>
</div>
</dialog>
<a-entity
class="interactable"
position="-1 1.5 -1"
geometry="primitive:box;width:3;height:3;"
material="src:https://aomediacodec.github.io/av1-avif/testFiles/Link-U/kimono.avif;side:double;"
@click="${this.#showInfo}"
>
</a-entity>
`
}
}
export default SampleElement
@query('dialog') searches the whole scene, so this works as long as the world has only one <dialog>.
Result
https://w3dge.io/YxT3P6/example_dialog
Import Scripts
Load ES modules from a CDN such as jsDelivr or unpkg with a dynamic import():
class SampleElement extends CapElement {
static schema = {}
// Do something when component first attached.
async init() {
// Import script module from jsdelivr or unpkg.com
const { helloWorld } = await import(
'https://cdn.jsdelivr.net/gh/vschetinger/captic/helloWorldExport.js'
)
helloWorld()
}
}
export default SampleElement
Materials
glTF models render with PBR materials on fast desktop GPUs and with cheaper Phong materials on Quest and phones. Materials your script creates follow the same rule only if you create them with these helpers:
this.standardMaterial(params, options)takes the same parameters asnew THREE.MeshStandardMaterial(params)and returns aMeshStandardMaterialon high quality, aMeshPhongMaterialon medium and aMeshLambertMaterialon low.await this.loadMaterial(manifestUrl, options)loads a material from the Captic material library by its manifest link (https://<cdn>/m/<id>.json) with all its textures and factors. Tiling textures are placed by the mesh's size in metres, so they appear at their real size on any mesh. Options:{ physical: true }for aMeshPhysicalMaterial,{ displacement: false }for coarse meshes,{ size: [w, h] }to use the mesh's 0–1 UVs for a surface of that size,{ worldScale: false }to keep the UVs and set the repeat yourself. The material and its textures are disposed when the script is removed.- Both accept
{ quality: 'high' }to keep one hero object PBR on every device.
The quality is chosen once when the world loads. Add ?materialQuality=M (or L, H) to the world URL to check how it looks on other devices. Phong and Lambert ignore roughness/metalness maps and the environment map, so don't rely on them for the look.
async init() {
this.floorMat = await this.loadMaterial('https://<cdn>/m/<id>.json')
this.trimMat = this.standardMaterial({ color: '#d97706', roughness: 0.4 })
const floor = new THREE.Mesh(new THREE.BoxGeometry(10, 0.1, 10), this.floorMat)
this.el.object3D.add(floor)
}
Create each material once and reuse it on every mesh of that surface. See PBR Materials & KTX2 Textures for how library textures are processed.
Merging static geometry
Every visible mesh costs one draw call per frame. For a procedural build, call this.bakeStatic(root) as the last step of init() (without an argument it uses this.el.object3D). It merges the static, non-transparent meshes under root into one mesh per material. It leaves out:
- meshes and groups with
userData.live = true(set it on anything you move, hide or recolour later; adduserData.bakeInside = trueto merge the children inside such a group), - meshes on
.interactable,.uiand other clickable child entities, InstancedMesh, multi-material meshes and normal transparent meshes.
The merged geometry is disposed when the script is removed.
Seats
Visitors can only sit where there is a seat anchor. Add one invisible anchor per seat (on benches and sofas, one about every 0.6 m):
<a-entity
class="interactable"
visible="false"
cap-component-seat="disabled: false"
position="0 0.5 0"
rotation="90 90 0"
></a-entity>
- Place the anchor on the seat surface. The avatar is placed 0.5 m below the anchor.
- The sitter faces the anchor's own yaw minus 90°: to face yaw
Y, userotation="90 (Y + 90) 0". cap-component-seat="disabled: false; firstPerson: true"switches the sitter to first person (desks, card tables, cockpits).- While seated, the avatar's physics body is paused, and it resumes at the seat point. Keep colliders clear of the seat point or the avatar is pushed away when standing up.
Multiplayer games
For anything with players, rounds, timers or scores, use this.joinGame(). It joins the world's shared game room, which runs the cycle lobby → countdown → match → results → lobby on the server: who is playing, the start, the timer and the results. Late arrivals see the right state, and world hosts get Start / Pause / Stop controls without extra code.
class TapRace_5d1c7e20 extends CapElement {
async init() {
try {
this.game = await this.joinGame({
name: 'tap-race', // game id; one room per script entity by default
lobbyTime: 15000, // ms of lobby countdown before an automatic start
roundTime: 20000, // ms match limit, 0 = none
countdown: 3000, // ms "get ready" before the match goes live
playersCanStart: true,
})
this.game.on('change', () => this.requestUpdate())
} catch (error) {
// No game server: keep the script usable offline
this.offline = true
this.requestUpdate()
}
}
onTap() {
const game = this.game
if (!game) return
if (!game.self?.playing) game.join()
else if (game.phase === 'match') game.addScore(1)
}
render() {
const game = this.game
if (!game) return html`<cap-uix-text value="${this.offline ? 'Offline' : 'Connecting...'}" align="center" font-size="0.05"></cap-uix-text>`
const status = game.phase === 'match' ? `GO! ${game.matchRemaining}s` : `Next round in ${game.lobbyRemaining}s`
return html`
<cap-uix-text value="${status}" align="center" font-size="0.05" position="0 0.2 0.01"></cap-uix-text>
<cap-uix-button class="ui" width="0.4" height="0.1" radius="0.02" color="#00e5ff" @click=${() => this.onTap()}>
<cap-uix-text value="${game.self?.playing ? 'TAP!' : 'JOIN'}" align="center" font-size="0.04" color="#09090b" position="0 0 0.01"></cap-uix-text>
</cap-uix-button>
`
}
}
export default TapRace_5d1c7e20
joinGame() resolves once the visitor has entered the world, and the room is left automatically when the script is removed. Always wrap it in try/catch.
The most used parts of the returned game object:
- State:
game.phase('lobby' | 'countdown' | 'match' | 'results'),game.round,game.paused,game.lobbyRemainingandgame.matchRemaining(whole seconds, for display only),game.players,game.playing,game.self,game.me,game.results,game.leaderboard. - Actions:
game.join(),game.leave(),game.start(),game.stop(),game.addScore(n),game.setScore(n),game.finish({ time, penalty }),game.setData(data),game.send(type, data)withgame.onMessage(type, (data, fromId) => ...). - Events:
game.on(name, fn)returns an unsubscribe function. Events include'change','phase','countdown','start','end','finish'and'error'. The'countdown'and'start'payloads carryreceivedAt, aperformance.now()timestamp: time a round withperformance.now() - e.receivedAt, not withDate.now().
Other options cover leaderboards (leaderboard: { metric: 'time' | 'score' }), solo time trials (freeRuns: true), host buttons (game.setHostActions()) and a shared results screen (game.showOutcome()).
Translations
- Declare strings with
static translations = { en: { ... }, de: { ... } }and read them withthis.t('key', { name: 'Ana' }), which fills{name}placeholders. You can also pass an inline map:this.t({ en: 'Rain', de: 'Regen' }). this.langis the visitor's language ('en','de', ...) andthis.localethe full tag ('en-US').- When the visitor changes language,
render()runs again. OverrideonLanguage(lang, locale)to redraw anything else, such as canvas textures.
Sound
Players set the Background, Media and Sound Effects volumes in their settings. Multiply the gain of sound you make yourself with Web Audio by this.audioVolume('effects') (or 'media', 'background'), a value from 0 to 1 that follows the slider live:
this.gain.gain.value = 0.5 * this.audioVolume('effects')
Debugging
- Errors thrown in
init,update,render,tickand the other lifecycle methods are logged to the browser console once, as[component-name] method() threw: .... el.__capElements['component-name']is the running script instance of an entity, for inspection in the console.- An AI agent connected over WebMCP can call
get_script_diagnostics, which returns each script's errors with counts, its average and peak tick cost, a snapshot of its state and itsjoinGame()rooms. Define adebugState()method to choose what the snapshot shows.
Building with an AI agent
An AI agent can write, attach and edit scripts in the world open in your browser. See WebMCP — In-Browser AI Tools.