@drawcall/acta
Acta is a JSON-based language to define humanoid character behaviors for three.js.
Important: You should not create these behaviors yourself, rather use the createCharacter tool to generate a character and editCharacter to modify it. When prompting, describe character states and transitions (e.g. "idle", "waving") — these can then be driven via the update method.
Important: You cannot provide or create animation assets. The acta tool will use appropriate animations from its own library. Do not attempt to create animation files before calling createCharacter.
Quick Start
When using physics, you should pass an
applyMovecallback toCharacter.load(). This ensures movement is only applied during movement animations — preventing the character from sliding during idle, attack, or other non-movement states.
import { BvhCharacterPhysics, BvhPhysicsWorld } from '@pmndrs/viverse'
// Setup physics
const physicsWorld = new BvhPhysicsWorld()
const characterPhysics = new BvhCharacterPhysics(physicsWorld)
// Load character WITH applyMove callback — this is REQUIRED for proper physics const character = await Character.load((velocity, delta) => { characterPhysics.inputVelocity.copy(velocity) }) scene.add(character)
// In your control system, only compute the desired velocity const desiredVelocity = new Vector3(/* from input */)
// In render loop — pass velocity to character, let applyMove handle physics character.update(delta, { worldMoveVelocity: desiredVelocity /* ...other props */ }) characterPhysics.update(character, delta, {})
> **Why applyMove?** Without it, you'd set `characterPhysics.inputVelocity` directly, causing the character to slide even when idle or attacking. The `applyMove` callback is invoked by the animation system only when movement animations are playing.
### Quick Start (without Physics)
```typescript
const character = await Character.load()
scene.add(character)
// In render loop
character.update(delta, { wave: waveButtonPressed })
update()must be called every frame, even when no properties change. Always passworldMoveVelocity— theapplyMovecallback handles when to actually apply it based on the current animation state.
Attaching Items
Characters cannot hold items directly. Create items separately via createModel and attach to character bones:
import { VRMHumanBoneName } from '@pmndrs/viverse'
const rightHand = character.getBone(VRMHumanBoneName.RightHand)
rightHand?.add(swordModel)
Update Parameters
| Parameter | Description |
|---|---|
worldHeadDirection |
Where the head looks (Vector3) |
worldAimDirection |
Where to aim for pistol animations (Vector3) |
worldMoveVelocity |
Movement velocity in world space (Vector3) |
...properties |
Boolean properties evaluated by state machine conditions |
Automatic Walk/Run Selection: During the "move" animation, acta automatically blends between walk and run animations based on the magnitude of
worldMoveVelocity. Slower speeds play the walk animation, while faster speeds transition to run — no manual switching required.
Note: The "Move" animation is built-in and does not require a URL in
animationUrls. The system automatically provides walk/run animations that blend based onworldMoveVelocitymagnitude.
Automatic Rotation: Acta automatically rotates the character to face the
worldMoveVelocitydirection. Do not manually setcharacter.rotationor uselookAt()— useupdate()exclusively.
Events
Use animation events to synchronize game state with character animations. This is essential for state-driven logic where actions complete when animations finish.
character.behavior.addEventListener('animationStarted', ({ name }) => {})
character.behavior.addEventListener('animationFinished', ({ name }) => {})
Example: PlayerSystem with Reload
This EliCS system demonstrates the pattern of deriving character properties from game state, and using animation events to update that state:
import { createSystem, Entity } from 'elics'
import { Vector3 } from 'three'
import { Character } from './player.acta'
import { Player, Velocity } from './components'
const playerQueryConfig = {
players: { required: [Player, Velocity] },
}
export class PlayerSystem extends createSystem(playerQueryConfig) {
public readonly characters = new Map<Entity, Character>()
init() {
const create = async (entity: Entity) => {
const character = await Character.load()
scene.add(character)
this.characters.set(entity, character)
// Animation events update game state
character.behavior.addEventListener('animationFinished', ({ name }) => {
if (name === 'Reload') {
entity.setValue(Player, 'bullets', entity.getValue(Player, 'maxBullets'))
}
})
}
const destroy = (entity: Entity) => {
const character = this.characters.get(entity)!
scene.remove(character)
this.characters.delete(entity)
}
this.queries.players.entities.forEach('qualify', create)
this.queries.players.subscribe('qualify', create)
this.queries.players.subscribe('disqualify', destroy)
}
update(delta: number) {
this.queries.players.entities.forEach((entity: Entity) => {
const character = this.characters.get(entity)!
const velocity = entity.getVectorView(Velocity, 'value')
// isReloading derived from game state — true when out of ammo
const isReloading = entity.getValue(Player, 'bullets') === 0
// Always call update every frame
// Always pass worldMoveVelocity — applyMove controls when it's applied
character.update(delta, {
isReloading,
worldMoveVelocity: new Vector3(velocity[0], velocity[1], velocity[2]),
})
})
}
}
Key pattern:
- Properties like
isReloadingare derived from component state (bullets === 0) - Animation events update component state (refill bullets on
animationFinished) - This creates a natural flow: empty magazine →
isReloadingtrue → reload plays → animation finishes → bullets refilled →isReloadingfalse
Built-in Properties
| Property | Description |
|---|---|
isMoving |
Computed: true when worldMoveVelocity.length() > 0 |
Troubleshooting: If
isMovingremainsfalse, verify that you are passing a non-zeroworldMoveVelocitytocharacter.update(). You do NOT need to passisMovingmanually—it is computed automatically.
Physics Integration (applyMove)
Pass an applyMove callback to Character.load() to integrate with physics (see physics.md). This ensures movement is only applied while movement animations are playing — preventing the character from sliding during idle or other non-movement states.
const characterPhysics = new BvhCharacterPhysics(world)
const character = await Character.load((velocity, delta) => {
characterPhysics.inputVelocity.copy(velocity)
})
Jumping and Falling
Pass an applyJump callback to Character.load() for jump physics:
const character = await Character.load(
(velocity, delta) => {
characterPhysics.inputVelocity.copy(velocity)
},
() => characterPhysics.applyVelocity(new Vector3(0, 10, 0)), // applyJump
)
Subscribe to JumpAction and set isJumping to true (see actions.md):
import { JumpAction } from '@pmndrs/viverse'
JumpAction.subscribe(
() => {
isJumping = true
},
{ signal: abortController.signal },
)
Sync isGrounded from physics each frame:
character.update(delta, {
isGrounded: characterPhysics.isGrounded,
isJumping,
worldMoveVelocity: desiredVelocity,
})
Reset isJumping when landing animation completes:
character.behavior.addEventListener('animationFinished', ({ name }) => {
if (name === 'Jump_Land') {
isJumping = false
}
})
Why applyJump? Like
applyMove, it ensures physics is only applied when the animation starts.