# AI Agent Behaviors in Hyperfy
```
http://localhost:3001/agents
http://localhost:3001/AGENT_ID/hyperfy
b850bc30-45f8-0041-a00a-83df46d8555d
http://localhost:3001/b850bc30-45f8-0041-a00a-83df46d8555d/hyperfy
```
## Finite State Machine (FSM) Agent
### Core FSM States and Transitions
```mermaid
stateDiagram-v2
direction LR
state "Idle" as idle {
[*] --> ShowNametag
ShowNametag --> PlayIdleAnimation
}
state "Speaking" as speaking {
[*] --> ShowBubble
ShowBubble --> HideNametag
}
state "Emoting" as emoting {
[*] --> PlayAnimation
}
state "Looking" as looking {
[*] --> CalculateAngle
CalculateAngle --> RotateAgent
}
[*] --> idle
idle --> speaking: app.on('say')
speaking --> idle: timer > BUBBLE_TIME
idle --> emoting: app.on('emote')
emoting --> idle: timer > EMOTE_TIME
idle --> looking: app.on('look')
looking --> idle: timer > LOOK_TIME
note right of idle
Default state
Shows nametag
Plays idle animation
end note
note right of speaking
Shows speech bubble
Displays message text
Timer: 5 seconds
end note
note right of emoting
Plays custom animation
Timer: 2 seconds
end note
note right of looking
Faces target player
Timer: 5 seconds
end note
```
First, let's look at how the states are defined. The code initializes state timing constants at the very beginning:
```javascript
const BUBBLE_TIME = 5 // Speaking state duration
const EMOTE_TIME = 2 // Emoting state duration
const LOOK_TIME = 5 // Looking state duration
```
The client-side FSM maintains its state through the data object:
```javascript
const data = {} // Empty object to track current states
```
```mermaid
sequenceDiagram
participant S as Server
participant E as Event Handlers
participant D as Data Object
participant U as Update Loop
S->>E: Send Command
E->>D: Initialize Timer
D->>U: Check Active States
U->>D: Update Timers
U->>D: Reset if Expired
```
### State Handler Flow
The FSM has three main state handlers, each triggered by specific events:
1. Speaking State:
```javascript
app.on('say', value => {
data.say = { timer: 0 } // Initialize speaking state
nametag.active = false // Hide nametag
bubbleText.value = value // Set speech content
bubble.active = true // Show speech bubble
})
```
2. Emoting State:
```javascript
app.on('emote', url => {
data.emote = { timer: 0 } // Initialize emoting state
vrm.setEmote(url) // Play animation
})
```
3. Looking State:
```javascript
app.on('look', playerId => {
data.look = { playerId, timer: 0 } // Initialize looking state
})
```
The state transitions are managed in the update loop, which checks and updates all active states:
```javascript
app.on('update', delta => {
// Speaking state management
if (data.say) {
data.say.timer += delta
if (data.say.timer > BUBBLE_TIME) {
data.say = null // Reset state
bubble.active = false // Hide bubble
nametag.active = true // Show nametag
}
}
// Emoting state management
if (data.emote) {
data.emote.timer += delta
if (data.emote.timer > EMOTE_TIME) {
data.emote = null // Reset state
vrm.setEmote(idleEmoteUrl) // Return to idle animation
}
}
// Looking state management
if (data.look) {
const player = world.getPlayer(data.look.playerId)
if (player) {
// Calculate direction and rotate agent
const direction = v1.copy(player.position).sub(vrm.position)
direction.y = 0
const angle = Math.atan2(direction.x, direction.z) + Math.PI
vrm.quaternion.setFromAxisAngle(UP, angle)
}
data.look.timer += delta
if (data.look.timer > LOOK_TIME) {
data.look = null // Reset state
}
}
})
```
### Server-Client Communication Flow
```mermaid
graph TD
subgraph Server
A[Event Detection] --> B[Webhook Notification]
B --> C[Command Generation]
end
subgraph Client FSM
D[Command Reception] --> E[State Initialization]
E --> F[Visual Updates]
F --> G[Timer Management]
end
C -->|app.send| D
```
The server side of the FSM handles event detection and command generation. Here's how it processes events:
```javascript
world.on('enter', player => {
info.events.push({
type: 'player-enter',
playerId: player.entityId,
})
changed = true
})
world.on('chat', msg => {
if (msg.fromId === app.instanceId) return
if(!msg.body) return
info.events.push({
type: 'chat',
...msg,
})
if (info.events.length > 16) {
info.events.shift()
}
changed = true
})
```
When events occur, the server communicates with an external webhook to determine state changes:
```javascript
async function notify() {
if (!config.url) return
changed = false
notifying = true
try {
const resp = await fetch(config.url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(info),
})
data = await resp.json()
} catch (err) {
console.error('notify failed')
}
// Process webhook response and trigger state changes
if (data.say && data.say != "null") {
app.send('say', data.say)
}
if (data.emote) {
app.send('emote', emoteUrls[data.emote])
}
if (data.look) {
app.send('look', data.look)
}
}
```
This implementation demonstrates key FSM characteristics:
- Well-defined states (speaking, emoting, looking)
- Clear state transitions triggered by events or timers
- State-specific behaviors and visual representations
- Automatic state reset mechanisms
- Centralized state management through the data object
Code:
```javascript
const BUBBLE_TIME = 5
const EMOTE_TIME = 2
const LOOK_TIME = 5
const UP = new Vector3(0, 1, 0)
const v1 = new Vector3()
const v2 = new Vector3()
const v3 = new Vector3()
const q1 = new Quaternion()
const q2 = new Quaternion()
const m1 = new Matrix4()
const vrm = app.get('avatar')
// SERVER
if (world.isServer) {
const config = app.config
// send initial state
const state = {
ready: true,
}
app.state = state
app.send('state', state)
// spawn controller
const ctrl = app.create('controller')
ctrl.position.copy(app.position)
world.add(ctrl)
ctrl.quaternion.copy(app.quaternion)
ctrl.add(vrm)
// read emotes
const emoteUrls = {}
if (config.emote1Name && config.emote1?.url) {
emoteUrls[config.emote1Name] = config.emote1.url
}
if (config.emote2Name && config.emote2?.url) {
emoteUrls[config.emote2Name] = config.emote2.url
}
if (config.emote3Name && config.emote3?.url) {
emoteUrls[config.emote3Name] = config.emote3.url
}
if (config.emote4Name && config.emote4?.url) {
emoteUrls[config.emote4Name] = config.emote4.url
}
// observe environment
let changed = true
let notifying = false
const info = {
world: {
id: null, // todo
name: null, // todo
url: null, // todo
context: config.context || 'You are in a virtual world powered by Hyperfy',
},
you: {
id: app.instanceId,
name: config.name,
},
emotes: Object.keys(emoteUrls),
triggers: [],
events: [],
}
world.on('enter', player => {
info.events.push({
type: 'player-enter',
playerId: player.entityId,
})
changed = true
})
world.on('leave', player => {
info.events.push({
type: 'player-leave',
playerId: player.entityId,
})
changed = true
})
world.on('chat', msg => {
if (msg.fromId === app.instanceId) return
if(!msg.body) return
info.events.push({
type: 'chat',
...msg,
})
if (info.events.length > 16) {
info.events.shift()
}
changed = true
})
// DEBUG
// app.send('say', 'Test!')
// app.send('emote', emoteUrls.wave)
async function notify() {
if (!config.url) return
changed = false
notifying = true
console.log('notifying...', info)
let data
try {
const resp = await fetch(config.url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(info),
})
data = await resp.json()
} catch (err) {
console.error('notify failed')
}
notifying = false
if (!data) return
console.log(data)
if (data.say && data.say != "null") {
app.send('say', data.say)
const msg = {
id: uuid(),
from: config.name + ' (agent)',
fromId: app.instanceId,
body: data.say,
createdAt: world.getTimestamp(),
}
world.chat(msg, true)
info.events.push({
type: 'chat',
...msg,
})
}
if (data.emote) {
const url = emoteUrls[data.emote]
app.send('emote', url)
}
if (data.look) {
app.send('look', data.look)
}
}
app.on('fixedUpdate', delta => {
if (changed && !notifying) {
notify()
}
// const v1 = new Vector3(0,0,1)
// app.on('fixedUpdate', delta => {
// ctrl.move(v1.set(0,0,1).multiplyScalar(1 * delta))
// })
})
}
// CLIENT
if (world.isClient) {
const config = app.config
const idleEmoteUrl = config.emote0?.url
world.attach(vrm)
let state = app.state
if (state.ready) {
init()
} else {
world.remove(vrm)
app.on('state', _state => {
state = _state
init()
})
}
// setup bubble
const bubble = app.create('ui')
bubble.width = 300
bubble.height = 512
bubble.size = 0.005
bubble.pivot = 'bottom-center'
bubble.billboard = 'full'
bubble.justifyContent = 'flex-end'
bubble.alignItems = 'center'
bubble.position.y = 2
bubble.active = false
const bubbleBox = app.create('uiview')
bubbleBox.backgroundColor = 'rgba(0, 0, 0, 0.95)'
bubbleBox.borderRadius = 20
bubbleBox.padding = 20
bubble.add(bubbleBox)
const bubbleText = app.create('uitext')
bubbleText.color = 'white'
bubbleText.fontWeight = 100
bubbleText.lineHeight = 1.4
bubbleText.fontSize = 16
bubbleText.value = '...'
bubbleBox.add(bubbleText)
vrm.add(bubble)
// setup nametag
const nametag = app.create('nametag')
nametag.label = config.name
nametag.position.y = 2
vrm.add(nametag)
function init() {
world.add(vrm)
vrm.setEmote(idleEmoteUrl)
}
const data = {}
app.on('say', value => {
data.say = { timer: 0 }
nametag.active = false
bubbleText.value = value
bubble.active = true
})
app.on('emote', url => {
data.emote = { timer: 0 }
vrm.setEmote(url)
})
app.on('look', playerId => {
data.look = { playerId, timer: 0 }
})
app.on('update', delta => {
if (data.say) {
data.say.timer += delta
if (data.say.timer > BUBBLE_TIME) {
data.say = null
bubble.active = false
nametag.active = true
}
}
if (data.emote) {
data.emote.timer += delta
if (data.emote.timer > EMOTE_TIME) {
data.emote = null
vrm.setEmote(idleEmoteUrl)
}
}
if (data.look) {
const player = world.getPlayer(data.look.playerId)
if (player) {
const direction = v1.copy(player.position).sub(vrm.position)
direction.y = 0
const angle = Math.atan2(direction.x, direction.z) + Math.PI
vrm.quaternion.setFromAxisAngle(UP, angle)
}
data.look.timer += delta
if (data.look.timer > LOOK_TIME) {
data.look = null
}
}
})
}
// CONFIG
app.configure(() => {
return [
{
key: 'name',
type: 'text',
label: 'Name',
},
{
key: 'context',
type: 'textarea',
label: 'Context',
},
{
key: 'url',
type: 'text',
label: 'URL',
},
{
key: 'emotes',
type: 'section',
label: 'Emotes',
},
{
key: 'emote0',
type: 'file',
label: 'Idle',
kind: 'emote',
},
{
key: 'emote',
type: 'switch',
label: 'Custom',
options: [
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
],
},
...customEmoteFields('1'),
...customEmoteFields('2'),
...customEmoteFields('3'),
...customEmoteFields('4'),
]
function customEmoteFields(n) {
return [
{
key: `emote${n}Name`,
type: 'text',
label: 'Name',
when: [{ key: 'emote', op: 'eq', value: n }],
},
{
key: `emote${n}`,
type: 'file',
label: 'Emote',
kind: 'emote',
when: [{ key: 'emote', op: 'eq', value: n }],
},
]
}
})
```
## Behavior Tree Agent
Let me help explain how behavior trees represent an evolution from finite state machines, breaking this down step by step with clear examples from our code.
### Core Architecture
First, let's understand how behavior trees differ fundamentally from FSMs:
In an FSM, we directly handle state transitions through events:
- Each state is isolated
- Transitions are explicitly defined
- States can't overlap
- Adding states increases complexity exponentially
Behavior trees instead use a hierarchical approach:
- Behaviors are organized in a tree structure
- Multiple behaviors can be active
- Complex behaviors are built from simple ones
- Adding behaviors is modular and scalable
```mermaid
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Speaking: say event
Speaking --> Idle: timer
Idle --> Emoting: emote event
Emoting --> Idle: timer
note right of Idle
FSM Architecture
Direct state transitions
end note
```
```mermaid
graph TD
subgraph "Behavior Tree Architecture"
Root[Root Selector] --> Chat[Chat Sequence]
Root --> Zones[Zone Sequence]
Chat --> C1[Check Chat]
Chat --> C2[Handle Chat]
Zones --> Z1[Check Distance]
Zones --> Z2[Handle Zone]
end
```
### Core Building Blocks
Let's examine the fundamental components that make behavior trees work:
```javascript
// Base node that all others extend from
class BehaviorNode {
constructor(name) {
this.name = name
}
tick(context) { } // Abstract method each node implements
}
// Runs children in sequence until one fails
class Sequence extends BehaviorNode {
tick(context) {
for (const child of this.children) {
if (child.tick(context) !== 'SUCCESS') {
return 'FAILURE'
}
}
return 'SUCCESS'
}
}
// Checks if a behavior can run
class ConditionNode extends BehaviorNode {
tick(context) {
return this.condition(context) ? 'SUCCESS' : 'FAILURE'
}
}
// Performs actual behavior
class ActionNode extends BehaviorNode {
tick(context) {
return this.action(context)
}
}
```
### Execution Flow
Here's how the behavior tree processes each frame:
```mermaid
sequenceDiagram
participant C as Context Update
participant R as Root Node
participant S as Sequences
participant A as Actions
C->>R: New frame starts
R->>S: Check conditions
S->>A: Run valid actions
A-->>R: Return status
R-->>C: Update complete
```
### Practical Example
Let's look at how we handle chat interactions in both paradigms:
#### FSM Style - Direct event handler
```javascript
app.on('say', value => {
data.say = { timer: 0 }
nametag.active = false
bubbleText.value = value
bubble.active = true
})
```
#### Behavior Tree - Modular Components
```javascript
// Behavior Tree Style - Modular components
new Sequence('chatHandler', [
// First check if we can chat
new ConditionNode('hasPendingChat', () =>
pendingChat !== null
),
// Then check distance
new ConditionNode('isInRange', context =>
context.playerDistance <= 2
),
// Finally handle the chat
new ActionNode('handleChat', () => {
showBubble(pendingChat.message)
sendChat(pendingChat.message)
pendingChat = null
return 'SUCCESS'
})
])
```
### Complex Behavior Composition
The real power comes from combining nodes for complex behaviors:
```mermaid
graph TD
R[Root] --> S1[Chat Handler]
R --> S2[Zone Handler]
R --> S3[Idle Handler]
S1 --> C1[Has Chat?]
S1 --> C2[In Range?]
S1 --> A1[Process Chat]
S2 --> C3[Check Distance]
S2 --> C4[Zone Changed?]
S2 --> A2[Update Zone]
```
#### Example of interaction sequence
```javascript
const behaviorTree = new Selector('root', [
new Sequence('closeInteraction', [
// Only runs for nearby players
new ConditionNode('isVeryClose', context =>
context.playerDistance <= 2
),
// Handles multiple behaviors in sequence
new ActionNode('greet', context => {
if (currentZone !== 'close') {
currentZone = 'close'
data.emote = { timer: 0, type: 'bow' }
vrm.setEmote(config.emote1?.url)
showBubble("Perfect! Now we can talk.")
}
updateRotation(context)
return 'SUCCESS'
})
])
])
```
### Benefits over FSMs
1. Modularity
- Each node is self-contained
- Behaviors can be easily added/removed
- Common behaviors can be reused
2. Readability
- Tree structure visualizes logic flow
- Conditions and actions are clearly separated
- Behavior hierarchy is explicit
3. Maintainability
- Changes are localized to specific nodes
- Testing can focus on individual behaviors
- Complex behaviors are built from simple ones
4. Flexibility
- Multiple behaviors can run together
- Priority handling is built-in
- State transitions are implicit
### Code
```javascript
const BUBBLE_TIME = 5
const EMOTE_TIME = 2
const LOOK_TIME = 5
const UP = new Vector3(0, 1, 0)
const v1 = new Vector3()
const vrm = app.get('avatar')
// SERVER
if (world.isServer) {
const config = app.config
const state = { ready: true }
app.state = state
app.send('state', state)
const ctrl = app.create('controller')
ctrl.position.copy(app.position)
world.add(ctrl)
ctrl.quaternion.copy(app.quaternion)
ctrl.add(vrm)
const emoteUrls = {}
if (config.emote1Name && config.emote1?.url) {
emoteUrls[config.emote1Name] = config.emote1.url
}
if (config.emote2Name && config.emote2?.url) {
emoteUrls[config.emote2Name] = config.emote2.url
}
if (config.emote3Name && config.emote3?.url) {
emoteUrls[config.emote3Name] = config.emote3.url
}
if (config.emote4Name && config.emote4?.url) {
emoteUrls[config.emote4Name] = config.emote4.url
}
let changed = true
let notifying = false
const info = {
world: {
id: null,
name: null,
url: null,
context: config.context || 'You are in a virtual world powered by Hyperfy'
},
you: {
id: app.instanceId,
name: config.name,
},
emotes: Object.keys(emoteUrls),
triggers: [],
events: [],
}
world.on('enter', player => {
info.events.push({
type: 'player-enter',
playerId: player.id
})
changed = true
})
world.on('leave', player => {
info.events.push({
type: 'player-leave',
playerId: player.id
})
changed = true
})
world.on('chat', msg => {
if (msg.fromId === app.instanceId) return
if (msg.skipBehavior) return
const player = world.getPlayer(msg.fromId)
if (!player) return
// Add distance check before processing any chat events
const distance = player.position.distanceTo(ctrl.position)
if (distance <= 2) {
info.events.push({ type: 'chat', ...msg })
changed = true
}
})
async function notify() {
if (!config.url || notifying) return
changed = false
notifying = true
try {
const resp = await fetch(config.url, {
headers: { 'Content-Type': 'application/json' },
method: 'POST',
body: JSON.stringify(info)
})
const data = await resp.json()
if (data?.say) app.send('say', data.say)
if (data?.emote) app.send('emote', emoteUrls[data.emote])
if (data?.look) app.send('look', data.look)
} catch (err) {
console.error('notify failed:', err)
}
notifying = false
}
app.on('fixedUpdate', delta => {
if (changed && !notifying) notify()
})
}
// CLIENT
if (world.isClient) {
class BehaviorNode {
constructor(name) {
this.name = name
}
tick(context) { }
}
class Selector extends BehaviorNode {
constructor(name, children) {
super(name)
this.children = children
}
tick(context) {
for (const child of this.children) {
const status = child.tick(context)
if (status === 'SUCCESS' || status === 'RUNNING') {
return status
}
}
return 'FAILURE'
}
}
class Sequence extends BehaviorNode {
constructor(name, children) {
super(name)
this.children = children
}
tick(context) {
for (const child of this.children) {
const status = child.tick(context)
if (status !== 'SUCCESS') {
return status
}
}
return 'SUCCESS'
}
}
class ConditionNode extends BehaviorNode {
constructor(name, condition) {
super(name)
this.condition = condition
}
tick(context) {
return this.condition(context) ? 'SUCCESS' : 'FAILURE'
}
}
class ActionNode extends BehaviorNode {
constructor(name, action) {
super(name)
this.action = action
}
tick(context) {
return this.action(context)
}
}
const config = app.config
const idleEmoteUrl = config.emote0?.url
let currentZone = null
let messageCounter = 0
let pendingChat = null
const farMessages = [
"I can barely see you from here!",
"You're too far away to talk!",
"Come closer if you want to chat!",
"I can see you in the distance!"
]
const mediumMessages = [
"Almost there, just a bit closer!",
"I still can't quite hear you!",
"A few more steps closer please!",
"That's better, but come a little closer!"
]
function getNextMessage(messages) {
return messages[messageCounter++ % messages.length]
}
function sendChat(message, skipBehavior = false) {
world.chat({
id: uuid(),
from: config.name + ' (agent)',
fromId: app.instanceId,
body: message,
createdAt: world.getTimestamp(),
skipBehavior
}, true)
}
function showBubble(message) {
data.say = { timer: 0 }
bubble.active = true
nametag.active = false
bubbleText.value = message
}
function init() {
world.add(vrm)
vrm.setEmote(idleEmoteUrl)
}
// UI Setup
world.attach(vrm)
let state = app.state
if (state.ready) {
init()
} else {
world.remove(vrm)
app.on('state', _state => {
state = _state
init()
})
}
const bubble = app.create('ui')
bubble.width = 300
bubble.height = 512
bubble.size = 0.005
bubble.pivot = 'bottom-center'
bubble.billboard = 'full'
bubble.justifyContent = 'flex-end'
bubble.alignItems = 'center'
bubble.position.y = 2
bubble.active = false
const bubbleBox = app.create('uiview')
bubbleBox.backgroundColor = 'rgba(0, 0, 0, 0.95)'
bubbleBox.borderRadius = 20
bubbleBox.padding = 20
bubble.add(bubbleBox)
const bubbleText = app.create('uitext')
bubbleText.color = 'white'
bubbleText.fontWeight = 100
bubbleText.lineHeight = 1.4
bubbleText.fontSize = 16
bubbleText.value = '...'
bubbleBox.add(bubbleText)
vrm.add(bubble)
const nametag = app.create('nametag')
nametag.label = config.name
nametag.position.y = 2
vrm.add(nametag)
function updateRotation(context) {
if (context.player && context.playerDistance <= 10) {
const direction = v1.copy(context.player.position).sub(vrm.position)
direction.y = 0
const angle = Math.atan2(direction.x, direction.z) + Math.PI
vrm.quaternion.setFromAxisAngle(UP, angle)
}
}
const data = {}
const behaviorTree = new Selector('root', [
new Sequence('apiChatHandler', [
new ConditionNode('hasPendingApiChat', () =>
pendingChat && typeof pendingChat === 'object' && pendingChat.fromApi
),
new ActionNode('handleApiChat', () => {
showBubble(pendingChat.message)
sendChat(pendingChat.message, true)
pendingChat = null
return 'SUCCESS'
})
]),
new Sequence('playerChatHandler', [
new ConditionNode('hasPendingPlayerChat', () =>
pendingChat && typeof pendingChat === 'string'
),
new ActionNode('handlePlayerChat', context => {
if (context.playerDistance > 10) {
sendChat("I can't hear you from that far away! Come closer if you want to talk!", true)
} else if (context.playerDistance > 2) {
sendChat("If you want to speak to me, come closer!", true)
}
pendingChat = null
return 'SUCCESS'
})
]),
new Sequence('closeInteraction', [
new ConditionNode('isVeryClose', context => context.playerDistance <= 2),
new ActionNode('greet', context => {
if (currentZone !== 'close') {
currentZone = 'close'
data.emote = { timer: 0, type: 'bow' }
if (config.emote1?.url) {
vrm.setEmote(config.emote1.url)
}
showBubble("Ah, perfect! Now we can talk properly.")
sendChat("Ah, perfect! Now we can talk properly.", true)
}
updateRotation(context)
return 'SUCCESS'
})
]),
new Sequence('mediumInteraction', [
new ConditionNode('isMediumDistance', context =>
context.playerDistance > 2 && context.playerDistance <= 5
),
new ActionNode('mediumDistance', context => {
if (currentZone !== 'medium') {
currentZone = 'medium'
data.emote = { timer: 0, type: 'wave' }
if (config.emote2?.url) {
vrm.setEmote(config.emote2.url)
}
const message = getNextMessage(mediumMessages)
showBubble(message)
sendChat(message, true)
}
updateRotation(context)
return 'SUCCESS'
})
]),
new Sequence('farInteraction', [
new ConditionNode('isFarDistance', context =>
context.playerDistance > 5 && context.playerDistance <= 10
),
new ActionNode('farDistance', context => {
if (currentZone !== 'far') {
currentZone = 'far'
const message = getNextMessage(farMessages)
showBubble(message)
sendChat(message, true)
}
updateRotation(context)
return 'SUCCESS'
})
]),
new ActionNode('idle', context => {
if (currentZone !== 'idle') {
currentZone = 'idle'
if (!data.emote) {
vrm.setEmote(idleEmoteUrl)
}
bubble.active = false
nametag.active = true
}
return 'SUCCESS'
})
])
function updateContext(delta) {
const context = {
delta,
playerDistance: Infinity,
player: null
}
const player = world.getPlayer()
if (player) {
context.player = player
context.playerDistance = player.position.distanceTo(vrm.position)
}
return context
}
world.on('chat', msg => {
if (msg.fromId !== app.instanceId && !msg.skipBehavior) {
pendingChat = msg.body
}
})
app.on('say', value => {
pendingChat = { message: value, fromApi: true }
})
app.on('emote', url => {
if (currentZone !== 'idle') {
data.emote = { timer: 0 }
vrm.setEmote(url)
}
})
app.on('update', delta => {
const context = updateContext(delta)
behaviorTree.tick(context)
if (data.say?.timer !== undefined) {
data.say.timer += delta
if (data.say.timer > BUBBLE_TIME) {
data.say = null
bubble.active = false
nametag.active = true
}
}
if (data.emote?.timer !== undefined) {
data.emote.timer += delta
if (data.emote.timer > EMOTE_TIME) {
data.emote = null
vrm.setEmote(idleEmoteUrl)
}
}
})
}
// Configuration
app.configure(() => [
{ key: 'name', type: 'text', label: 'Name' },
{ key: 'context', type: 'textarea', label: 'Context' },
{ key: 'url', type: 'text', label: 'URL' },
{ key: 'emotes', type: 'section', label: 'Emotes' },
{ key: 'emote0', type: 'file', label: 'Idle', kind: 'emote' },
{
key: 'emote',
type: 'switch',
label: 'Custom',
options: [1, 2, 3, 4].map(n => ({ label: n.toString(), value: n.toString() }))
},
...[1, 2, 3, 4].flatMap(n => [
{
key: `emote${n}Name`,
type: 'text',
label: 'Name',
when: [{ key: 'emote', op: 'eq', value: n.toString() }]
},
{
key: `emote${n}`,
type: 'file',
label: 'Emote',
kind: 'emote',
when: [{ key: 'emote', op: 'eq', value: n.toString() }]
}
])
])
```
<!--
### Core Components and Flow
The behavior tree processes behaviors in a structured way. Each frame:
1. The context object is updated with new delta time
2. The root node "ticks" (processes) its children
3. Each node returns a status: SUCCESS, FAILURE, or RUNNING
4. Visual updates occur based on the executed actions
Here's how this flow works:
```mermaid
sequenceDiagram
participant C as Context
participant T as Tree
participant N as Nodes
participant V as Visual
C->>T: Update Delta Time
T->>N: Tick Root Node
N->>N: Process Children
N->>V: Execute Actions
V->>C: Update State
```
```javascript
const BUBBLE_TIME = 5
const EMOTE_TIME = 2
const LOOK_TIME = 5
const UP = new Vector3(0, 1, 0)
const v1 = new Vector3()
const v2 = new Vector3()
const v3 = new Vector3()
const q1 = new Quaternion()
const q2 = new Quaternion()
const m1 = new Matrix4()
const vrm = app.get('avatar')
// SERVER
if (world.isServer) {
const config = app.config
const state = {
ready: true,
}
app.state = state
app.send('state', state)
const ctrl = app.create('controller')
ctrl.position.copy(app.position)
world.add(ctrl)
ctrl.quaternion.copy(app.quaternion)
ctrl.add(vrm)
const emoteUrls = {}
if (config.emote1Name && config.emote1?.url) {
emoteUrls[config.emote1Name] = config.emote1.url
}
if (config.emote2Name && config.emote2?.url) {
emoteUrls[config.emote2Name] = config.emote2.url
}
if (config.emote3Name && config.emote3?.url) {
emoteUrls[config.emote3Name] = config.emote3.url
}
if (config.emote4Name && config.emote4?.url) {
emoteUrls[config.emote4Name] = config.emote4.url
}
// observe environment
let changed = true
let notifying = false
const info = {
world: {
id: null,
name: null,
url: null,
context: config.context || 'You are in a virtual world powered by Hyperfy'
},
you: {
id: app.instanceId,
name: config.name,
},
emotes: Object.keys(emoteUrls),
triggers: [],
events: [],
}
world.on('enter', player => {
info.events.push({
type: 'player-enter',
playerId: player.id
})
changed = true
})
world.on('leave', player => {
info.events.push({
type: 'player-leave',
playerId: player.id
})
changed = true
})
world.on('chat', msg => {
if (msg.fromId === app.instanceId) return
const player = world.getPlayer(msg.fromId)
if (!player) return
const distance = player.position.distanceTo(ctrl.position)
// If too far, just send the "come closer" message
if (distance > 2) {
const response = {
id: uuid(),
from: config.name + ' (agent)',
fromId: app.instanceId,
body: "If you want to speak to me, come closer!",
createdAt: world.getTimestamp()
}
world.chat(response, true)
return
}
// If in range, process with API
info.events.push({ type: 'chat', ...msg })
changed = true
})
async function notify() {
// Only notify if properly configured
if (!config.url || notifying) return
changed = false
notifying = true
console.log('notifying...', info)
let data
try {
const resp = await fetch(config.url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(info)
})
data = await resp.json()
} catch (err) {
console.error('notify failed')
}
notifying = false
if (!data) return
console.log(data)
if (data.say) {
app.send('say', data.say)
}
if (data.emote) {
const url = emoteUrls[data.emote]
app.send('emote', url)
}
if (data.look) {
app.send('look', data.look)
}
}
app.on('fixedUpdate', delta => {
if (changed && !notifying) {
notify()
}
})
}
// CLIENT
if (world.isClient) {
// ... Behavior Tree node classes stay the same ...
class BehaviorNode {
constructor(name) {
this.name = name
}
tick(context) { }
}
class Selector extends BehaviorNode {
constructor(name, children) {
super(name)
this.children = children
}
tick(context) {
for (const child of this.children) {
const status = child.tick(context)
if (status === 'SUCCESS' || status === 'RUNNING') {
return status
}
}
return 'FAILURE'
}
}
class Sequence extends BehaviorNode {
constructor(name, children) {
super(name)
this.children = children
}
tick(context) {
for (const child of this.children) {
const status = child.tick(context)
if (status !== 'SUCCESS') {
return status
}
}
return 'SUCCESS'
}
}
class ConditionNode extends BehaviorNode {
constructor(name, condition) {
super(name)
this.condition = condition
}
tick(context) {
return this.condition(context) ? 'SUCCESS' : 'FAILURE'
}
}
class ActionNode extends BehaviorNode {
constructor(name, action) {
super(name)
this.action = action
}
tick(context) {
return this.action(context)
}
}
const config = app.config
const idleEmoteUrl = config.emote0?.url
let currentZone = null
let messageCounter = 0
let pendingChat = null
const farMessages = [
"I can barely see you from here!",
"You're too far away to talk!",
"Come closer if you want to chat!",
"I can see you in the distance!"
]
const mediumMessages = [
"Almost there, just a bit closer!",
"I still can't quite hear you!",
"A few more steps closer please!",
"That's better, but come a little closer!"
]
const getNextMessage = (messages) => {
const msg = messages[messageCounter % messages.length]
messageCounter++
return msg
}
function sendChat(message) {
const msg = {
id: uuid(),
from: config.name + ' (agent)',
fromId: app.instanceId,
body: message,
createdAt: world.getTimestamp()
}
world.chat(msg, true)
}
world.attach(vrm)
let state = app.state
if (state.ready) {
init()
} else {
world.remove(vrm)
app.on('state', _state => {
state = _state
init()
})
}
// setup bubble and nametag (stays the same)
const bubble = app.create('ui')
bubble.width = 300
bubble.height = 512
bubble.size = 0.005
bubble.pivot = 'bottom-center'
bubble.billboard = 'full'
bubble.justifyContent = 'flex-end'
bubble.alignItems = 'center'
bubble.position.y = 2
bubble.active = false
const bubbleBox = app.create('uiview')
bubbleBox.backgroundColor = 'rgba(0, 0, 0, 0.95)'
bubbleBox.borderRadius = 20
bubbleBox.padding = 20
bubble.add(bubbleBox)
const bubbleText = app.create('uitext')
bubbleText.color = 'white'
bubbleText.fontWeight = 100
bubbleText.lineHeight = 1.4
bubbleText.fontSize = 16
bubbleText.value = '...'
bubbleBox.add(bubbleText)
vrm.add(bubble)
const nametag = app.create('nametag')
nametag.label = config.name
nametag.position.y = 2
vrm.add(nametag)
function init() {
world.add(vrm)
vrm.setEmote(idleEmoteUrl)
}
function updateRotation(context) {
if (context.player && context.playerDistance <= 10) {
const direction = v1.copy(context.player.position).sub(vrm.position)
direction.y = 0
const angle = Math.atan2(direction.x, direction.z) + Math.PI
vrm.quaternion.setFromAxisAngle(UP, angle)
}
}
function handleChatAtDistance(context) {
if (!pendingChat) return 'SUCCESS'
if (context.playerDistance > 10) {
sendChat("I can't hear you from that far away! Come closer if you want to talk.")
pendingChat = null
return 'SUCCESS'
}
if (context.playerDistance > 2) {
sendChat("If you want to speak to me, come closer!")
pendingChat = null
return 'SUCCESS'
}
// If we're in close range, handle the chat normally
data.say = { timer: 0 }
bubble.active = true
nametag.active = false
bubbleText.value = pendingChat
sendChat(pendingChat)
pendingChat = null
return 'SUCCESS'
}
const data = {}
const behaviorTree = new Selector('root', [
// Chat handling (checked first)
new Sequence('chatHandler', [
new ConditionNode('hasPendingChat', (context) => {
return pendingChat !== null
}),
new ActionNode('handleChat', (context) => {
return handleChatAtDistance(context)
})
]),
// Zone-based behaviors (same as before)
new Sequence('closeInteraction', [
new ConditionNode('isVeryClose', (context) => {
return context.playerDistance <= 2
}),
new ActionNode('greet', (context) => {
if (currentZone !== 'close') {
currentZone = 'close'
data.emote = { timer: 0, type: 'bow' }
if (config.emote1?.url) {
vrm.setEmote(config.emote1.url)
}
const message = "Ah, perfect! Now we can talk properly."
data.say = { timer: 0 }
bubbleText.value = message
bubble.active = true
nametag.active = false
sendChat(message)
}
updateRotation(context)
return 'SUCCESS'
})
]),
new Sequence('mediumInteraction', [
new ConditionNode('isMediumDistance', (context) => {
return context.playerDistance > 2 && context.playerDistance <= 5
}),
new ActionNode('mediumDistance', (context) => {
if (currentZone !== 'medium') {
currentZone = 'medium'
data.emote = { timer: 0, type: 'wave' }
if (config.emote2?.url) {
vrm.setEmote(config.emote2.url)
}
const message = getNextMessage(mediumMessages)
data.say = { timer: 0 }
bubbleText.value = message
bubble.active = true
nametag.active = false
sendChat(message)
}
updateRotation(context)
return 'SUCCESS'
})
]),
new Sequence('farInteraction', [
new ConditionNode('isFarDistance', (context) => {
return context.playerDistance > 5 && context.playerDistance <= 10
}),
new ActionNode('farDistance', (context) => {
if (currentZone !== 'far') {
currentZone = 'far'
const message = getNextMessage(farMessages)
data.say = { timer: 0 }
bubbleText.value = message
bubble.active = true
nametag.active = false
sendChat(message)
}
updateRotation(context)
return 'SUCCESS'
})
]),
new ActionNode('idle', (context) => {
if (currentZone !== 'idle') {
currentZone = 'idle'
if (!data.emote) {
vrm.setEmote(idleEmoteUrl)
}
bubble.active = false
nametag.active = true
}
return 'SUCCESS'
})
])
function updateContext(delta) {
const context = {
delta,
playerDistance: Infinity,
player: null
}
const player = world.getPlayer()
if (player) {
context.player = player
context.playerDistance = player.position.distanceTo(vrm.position)
}
return context
}
world.on('chat', msg => {
if (msg.fromId === app.instanceId) return
pendingChat = msg.body
})
app.on('say', value => {
// Queue the chat message for the behavior tree to handle
pendingChat = value
})
app.on('emote', url => {
// Allow emotes in all ranges except idle
if (currentZone !== 'idle') {
data.emote = { timer: 0 }
vrm.setEmote(url)
}
})
app.on('update', delta => {
const context = updateContext(delta)
behaviorTree.tick(context)
if (data.say) {
data.say.timer += delta
if (data.say.timer > BUBBLE_TIME) {
data.say = null
bubble.active = false
nametag.active = true
}
}
if (data.emote) {
data.emote.timer += delta
if (data.emote.timer > EMOTE_TIME) {
data.emote = null
vrm.setEmote(idleEmoteUrl)
}
}
})
}
// CONFIG stays exactly the same...
app.configure(() => {
return [
{
key: 'name',
type: 'text',
label: 'Name',
},
{
key: 'context',
type: 'textarea',
label: 'Context',
},
{
key: 'url',
type: 'text',
label: 'URL',
},
{
key: 'emotes',
type: 'section',
label: 'Emotes',
},
{
key: 'emote0',
type: 'file',
label: 'Idle',
kind: 'emote',
},
{
key: 'emote',
type: 'switch',
label: 'Custom',
options: [
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
],
},
...customEmoteFields('1'),
...customEmoteFields('2'),
...customEmoteFields('3'),
...customEmoteFields('4'),
];
function customEmoteFields(n) {
return [
{
key: `emote${n}Name`,
type: 'text',
label: 'Name',
when: [{ key: 'emote', op: 'eq', value: n }],
},
{
key: `emote${n}`,
type: 'file',
label: 'Emote',
kind: 'emote',
when: [{ key: 'emote', op: 'eq', value: n }],
},
];
}
}); -->