Core Concepts

Actions

Actions are centralized functions for modifying the game world in a predictable manner.

Understanding Actions

Actions in Viber3D (powered by Koota) are centralized functions that modify the ECS world. They help avoid scattering spawn/remove/update logic in multiple places.

import { createActions } from 'koota'
import { IsPlayer, Health, Transform } from '../traits'

export const actions = createActions((world) => ({
  spawnPlayer: (position) => {
    return world.spawn(
      IsPlayer(),
      Transform({ position }),
      Health({ amount: 100 })
    )
  },
  destroyAllPlayers: () => {
    world.query(IsPlayer).forEach((player) => {
      player.destroy()
    })
  }
}))

Using Actions in React

With useActions, you get your action functions bound to the current world:

import { useActions } from 'koota/react'
import { actions } from '../actions'

function GameController() {
  const { spawnPlayer, destroyAllPlayers } = useActions(actions)
  
  useEffect(() => {
    // Spawn a player on mount
    spawnPlayer({ x: 0, y: 0, z: 0 })

    // Cleanup on unmount
    return () => destroyAllPlayers()
  }, [])
  
  return null
}

Best Practices

  1. Centralize: Store all your actions in a dedicated file (e.g. src/actions.ts or actions/ folder).
  2. Keep them pure: Actions should only modify the ECS state; avoid large side effects.
  3. Descriptive names: Use clear naming like spawnEnemyWave(), applyDamage(), etc.
  4. Cleanup logic: Provide actions to remove entities or reset traits as needed.

Action Composition

Actions can call one another to build complex flows:

export const actions = createActions((world) => ({
  spawnEnemy: (position) => {
    return world.spawn(IsEnemy(), Transform({ position }), Health({ amount: 50 }))
  },
  spawnEnemyWave: (count) => {
    for (let i = 0; i < count; i++) {
      const position = getRandomPosition()
      actions.spawnEnemy(position) // calling another action
    }
  }
}))

Conditional Updates

export const actions = createActions((world) => ({
  damageEntity: (entity, amount) => {
    if (entity.has(Health)) {
      entity.set(Health, (h) => {
        const newAmount = Math.max(0, h.amount - amount)
        if (newAmount <= 0) {
          actions.destroyEntity(entity)
        }
        return { ...h, amount: newAmount }
      })
    }
  },
  destroyEntity: (entity) => entity.destroy()
}))

Testing Actions

Actions are straightforward to test. You can create a test world, apply your action, and inspect the result:

describe('playerActions', () => {
  let world: World
  let boundActions: ReturnType<typeof actions>

  beforeEach(() => {
    world = createWorld()
    boundActions = actions.bindTo(world)
  })

  it('spawnPlayer creates a player entity', () => {
    const player = boundActions.spawnPlayer({ x: 0, y: 0, z: 0 })
    expect(player.has(IsPlayer)).toBe(true)
    expect(player.has(Health)).toBe(true)
  })

  it('destroyAllPlayers removes all player entities', () => {
    boundActions.spawnPlayer({ x: 0, y: 0, z: 0 })
    boundActions.spawnPlayer({ x: 1, y: 0, z: 0 })
    boundActions.destroyAllPlayers()
    expect(world.query(IsPlayer).length).toBe(0)
  })
})

Summary

Actions let you organize all your ECS modifications into a single, testable location—helping maintain clarity and consistency throughout your codebase.

  • Integrate Actions in React via useActions.
  • Combine with Systems for runtime logic and Traits for the data definition.
  • Keep your ECS usage clean, predictable, and well-structured.