{"id":"axp3cter/arbor","name":"arbor","scope":"axp3cter","platform":"roblox","description":"Composable, typed behavior trees for Roblox AI","version":"2.1.0","latest":"2.1.0","versions":["1.0.0","1.0.1","1.0.2","2.0.0","2.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":["The package archive does not include its license text; the license is declared in its manifest metadata."],"licenseVerified":false,"dependencies":{},"integrity":"9394baef44d4123640c39e8957cad6af675eb5318e6f19a84a9391667a88ad27","likes":0,"downloads":0,"install":"forest install axp3cter/arbor","url":"https://forest.dev/p/roblox/axp3cter/arbor","files":"https://api.forest.dev/ai/package/roblox/axp3cter/arbor/files","readme":"# Arbor\n\nComposable, typed behavior trees for Roblox AI.\n\n[Releases](https://github.com/Axp3cter/Arbor/releases) · [API Reference](#api-reference)\n\n## Install\n\n**Wally (Luau)**\n\n```toml\n[dependencies]\narbor = \"axp3cter/arbor@2.1.0\"\n```\n\n**npm (roblox-ts)**\n\n```\nnpm install @axpecter/arbor\n```\n\n**Direct download**\n\nGrab the latest `.rbxm` from [Releases](https://github.com/Axp3cter/Arbor/releases).\n\n## Quick Start\n\n```luau\nlocal bt = require(path.to.bt)\n\nlocal board = { target = nil :: Player?, health = 100 }\nlocal npc = script.Parent\n\nlocal root = bt.select {\n    bt.sequence {\n        bt.check(function(b) return b.health < 30 end),\n        bt.action(function(_b, agent)\n            agent:runAway()\n            return \"running\"\n        end),\n    },\n    bt.sequence {\n        bt.check(function(b) return b.target ~= nil end),\n        bt.action(function(_b, agent)\n            agent:attack()\n            return \"success\"\n        end),\n    },\n    bt.action(function(_b, agent)\n        agent:patrol()\n        return \"running\"\n    end),\n}\n\nlocal ctx = bt.run(root, board, npc, 10)\n\nnpc.Destroying:Once(function()\n    ctx:destroy()\nend)\n```\n\n## Concepts\n\n### Status\n\nEvery node returns one of three statuses each tick:\n\n| Status | Meaning |\n|---|---|\n| `\"success\"` | Done, it worked. |\n| `\"failure\"` | Done, it did not work. |\n| `\"running\"` | Still in progress. Tick again next frame. |\n\nThese are the only three values in the system. Composites and decorators make decisions based on which status their children return.\n\n### Board\n\nThe board is a shared data table that every node callback receives as its first argument. Put anything the AI needs to reason about on here: targets, health, flags, positions. You define the shape.\n\n```luau\nlocal board = {\n    target    = nil :: Player?,\n    health    = 100,\n    canSee    = false,\n    allies    = 0,\n    lastHeard = nil :: Vector3?,\n}\n```\n\nConditions only receive `(board)`. Actions receive `(board, agent, dt)`. If you need agent state in a condition, write it to the board first (a poll is the natural place for that).\n\n### Conditions\n\nConditions read the board and return `\"success\"` or `\"failure\"`. They never return `\"running\"`. Use them as gates in sequences and selectors.\n\n```luau\nlocal hasTarget = bt.check(function(b) return b.target ~= nil end)\nlocal isHurt    = bt.check(function(b) return b.health < 30 end)\n```\n\n### Actions\n\nActions are where the NPC does work. Two forms exist depending on whether the work is instant or spans multiple frames.\n\n**Function form** runs every tick the action is active. No state is tracked internally. The handler receives `(board, agent, dt)` and must return a status.\n\n```luau\nlocal attack = bt.action(function(_b, agent)\n    agent:swingWeapon()\n    return \"success\"\nend)\n```\n\n**Table form** is for work that spans multiple frames. Three optional hooks:\n\n- `enter(board, agent, dt)` runs once on the first tick after activation. If it returns `\"running\"`, the action enters the tick phase. If it returns `\"success\"` or `\"failure\"`, the action completes immediately without ever calling `tick`.\n- `tick(board, agent, dt)` runs every subsequent tick while the action is `\"running\"`. Return `\"success\"` or `\"failure\"` to complete.\n- `halt(board, agent)` runs if the action is interrupted while `\"running\"` (a parent halts it). Not called on normal completion. Does not receive `dt`.\n\nAt least one of `enter` or `tick` must be provided. If `enter` is omitted, the action skips straight to `tick` on its first frame. If `tick` is omitted and `enter` returns `\"running\"`, the action stays `\"running\"` indefinitely until halted externally. This is a valid fire-and-forget pattern (start an animation, keep running until the branch switches).\n\n```luau\nlocal chase = bt.action({\n    enter = function(b, agent)\n        agent:pathTo(b.target)\n        return \"running\"\n    end,\n    tick = function(_b, agent)\n        return if agent:reachedTarget() then \"success\" else \"running\"\n    end,\n    halt = function(_b, agent)\n        agent:stopMoving()\n    end,\n})\n```\n\n### Composites\n\nComposites combine multiple nodes into control flow.\n\n**`bt.select`** runs children left to right and succeeds on the first child that succeeds. Re-evaluates from child 1 every tick. This means higher-priority branches automatically take over when their conditions become true. If a lower-priority child was `\"running\"`, it gets halted. Returns `\"failure\"` only if every child fails.\n\n```luau\nbt.select {\n    bt.sequence { isHurt, flee },       -- priority 1\n    bt.sequence { hasTarget, attack },  -- priority 2\n    patrol,                             -- priority 3\n}\n```\n\n**`bt.sequence`** runs children left to right and fails on the first child that fails. Unlike select, sequence uses sticky resume. It remembers which child was `\"running\"` and picks up there next tick. Earlier children that already succeeded are not re-evaluated. Returns `\"success\"` only if every child succeeds.\n\n```luau\nbt.sequence {\n    hasTarget,\n    canSee,\n    chase,\n}\n```\n\n**`bt.parallel(succeed, fail?)`** ticks all children every frame. Resolves when enough children have reached a terminal status. `succeed` is the number of children that must succeed for the parallel to return `\"success\"`. `fail` is the number that must fail for `\"failure\"`, defaulting to the total child count. Both thresholds must be > 0 and ≤ the child count.\n\nWhen a threshold is met mid-tick, all remaining active children are halted. If all children resolve without either threshold being met (possible when `succeed + fail > count`), the parallel returns `\"failure\"`.\n\n```luau\nbt.parallel(1) {        -- succeed when 1 child succeeds\n    chase,\n    attackLoop,\n}\n\nbt.parallel(2, 1) {     -- succeed when 2 succeed, fail when 1 fails\n    taskA,\n    taskB,\n    taskC,\n}\n```\n\nNote the curried API. `bt.parallel(succeed)` returns a function that takes the children table. This reads naturally in Luau thanks to call sugar: `bt.parallel(1) { ... }`.\n\n**`bt.random`** picks one child at random and sticks with it until it resolves (`\"success\"` or `\"failure\"`). While the chosen child returns `\"running\"`, it stays selected. On resolution, the selection is cleared and the next activation picks fresh. Optional weights make some children more likely.\n\n```luau\nbt.random({\n    patrol,\n    idleAnimation,\n}, { 3, 1 })  -- patrol is 3x more likely\n```\n\n### Decorators\n\nDecorators are chained methods on any node. Each returns a new node wrapping the original. Read left to right:\n\n```luau\nchase:timeout(6):retry(3)\n-- \"chase, with a 6-second timeout, retried up to 3 times\"\n```\n\n| Decorator | Behavior |\n|---|---|\n| `node:invert()` | Flips `\"success\"` ↔ `\"failure\"`. `\"running\"` passes through unchanged. |\n| `node:always(status)` | Forces `\"success\"` or `\"failure\"` when the child completes. `\"running\"` passes through. The child still runs until it finishes, then the forced status is returned. |\n| `node:loop(count?)` | **Counted** (with count): repeats the child up to N times. If the child returns `\"running\"`, the loop yields and resumes the count next tick. Stops immediately on `\"failure\"`. Returns `\"success\"` after N completions. **Infinite** (no count): ticks the child once per frame. On `\"success\"`, yields `\"running\"` to prevent spin. The child runs again next frame. Stops on `\"failure\"`. |\n| `node:cooldown(seconds)` | After the child succeeds, blocks re-entry for N seconds (wall-clock via `os.clock`). Returns `\"failure\"` during the cooldown window. The cooldown timestamp survives branch-level halts. If a selector switches away and comes back, the cooldown still applies. `ctx:stop()` and `ctx:destroy()` clear all state including cooldown timestamps. |\n| `node:timeout(seconds)` | Starts a wall-clock timer on entry. If the child is still `\"running\"` after N seconds, halts it and returns `\"failure\"`. Timer resets on normal completion. |\n| `node:retry(times)` | If the child fails, halts it (resetting internal state) and tries again, up to N total attempts. Returns `\"failure\"` after exhausting all attempts. Returns the child's `\"running\"` and `\"success\"` directly. |\n| `node:guard(check)` | Re-evaluates `check(board)` every tick before ticking the child. If the check returns false and the child was `\"running\"`, halts it. Returns `\"failure\"`. If the check returns false and the child was not running, returns `\"failure\"` without halting. |\n| `node:throttle(seconds)` | Limits how often the child is evaluated. If a cached terminal result (`\"success\"` or `\"failure\"`) exists and the interval has not elapsed, returns the cached result without ticking the child. Running children pass through every tick. Cache survives branch-level halts but is cleared by `stop()`/`destroy()`. |\n| `node:tag(name)` | Attaches a debug name string to the node. Currently inert. Intended for future debug tooling. |\n| `node:serve(polls...)` | Attaches poll nodes that tick before the child every frame. When the child is halted, the polls are halted too (timers reset). On re-entry, polls fire immediately. |\n\n### Poll Services\n\nPolls run a function on a wall-clock interval (`os.clock`) and always return `\"success\"`. The interval is independent of the context's tick rate. A 0.3s poll fires based on real elapsed time, not simulation ticks.\n\nAttach polls to nodes via `:serve()`. The polls are scoped to the served node's lifecycle: they tick every frame while the served branch is active, and their timers reset when the branch is halted.\n\n```luau\nlocal scan = bt.poll(0.3, function(b, agent)\n    b.target = agent:findNearestEnemy()\n    b.canSee = b.target ~= nil and agent:hasLineOfSight(b.target)\nend)\n\nlocal root = bt.select {\n    -- decision tree...\n} :serve(scan)\n```\n\n### Wait\n\n`bt.wait(seconds)` returns `\"running\"` until the specified duration has elapsed, then returns `\"success\"`. Duration is tracked by accumulating `ctx.dt`, so it measures simulation time, not wall-clock time. With a fixed-timestep runner at 10Hz, each tick advances by 0.1s of simulation time.\n\n```luau\nbt.sequence {\n    attack:cooldown(0.8),\n    bt.wait(0.2),        -- brief pause after attack\n} :loop()\n```\n\n### Context\n\nA tree is just a structure, a frozen graph of nodes. To run it, bind it to a board and an agent by creating a context. The context holds all runtime state (which child is running, timers, cooldown timestamps). One tree can be shared across many contexts with independent state.\n\n```luau\n-- Manual ticking:\nlocal ctx = bt.bind(root, board, npc)\nRunService.Heartbeat:Connect(function(dt)\n    ctx:tick(dt)\nend)\n\n-- Automatic runner at N Hz (fixed timestep):\nlocal ctx = bt.run(root, board, npc, 10)\n\n-- Automatic runner at frame rate (variable dt):\nlocal ctx = bt.run(root, board, npc)\n```\n\nWhen ticking manually, always pass `dt`. Omitting it defaults to `0`, which means time-based nodes like `bt.wait` and `node:timeout` will never make progress.\n\n`ctx:stop()` disconnects the runner, halts all running nodes (triggering their cleanup), and clears all internal state. After stop, calling `tick()` or `start()` begins a completely fresh run. No prior state survives.\n\n`ctx:destroy()` calls `stop()` and marks the context as dead. All subsequent `tick()` calls return `\"failure\"`. Idempotent.\n\nAlways call `ctx:destroy()` when the NPC is removed. Without it, the Heartbeat connection leaks.\n\n```luau\nnpc.Destroying:Once(function()\n    ctx:destroy()\nend)\n```\n\n## Full Example\n\n```luau\nlocal bt = require(path.to.bt)\n\ntype Board = {\n    target: Player?,\n    health: number,\n    canSee: boolean,\n    allies: number,\n    lastHeard: Vector3?,\n}\n\nlocal board: Board = {\n    target    = nil,\n    health    = 100,\n    canSee    = false,\n    allies    = 0,\n    lastHeard = nil,\n}\n\nlocal npc = script.Parent\n\n-- Conditions\n\nlocal hasTarget  = bt.check(function(b: Board) return b.target ~= nil end)\nlocal isHurt     = bt.check(function(b: Board) return b.health < 30 end)\nlocal canSee     = bt.check(function(b: Board) return b.canSee end)\nlocal hasAllies  = bt.check(function(b: Board) return b.allies > 0 end)\nlocal heardNoise = bt.check(function(b: Board) return b.lastHeard ~= nil end)\n\n-- Actions\n\nlocal attack = bt.action(function(_b: Board, agent)\n    agent:swingWeapon()\n    return \"success\"\nend)\n\nlocal callForHelp = bt.action(function(_b: Board, agent)\n    agent:shout()\n    return \"success\"\nend)\n\nlocal heal = bt.action(function(b: Board, agent)\n    agent:playAnimation(\"Heal\")\n    b.health = math.min(100, b.health + 30)\n    return \"success\"\nend)\n\nlocal patrol = bt.action(function(_b: Board, agent)\n    agent:walkToNextWaypoint()\n    return \"running\"\nend)\n\nlocal chase = bt.action({\n    enter = function(b: Board, agent)\n        agent:pathTo(b.target)\n        return \"running\"\n    end,\n    tick = function(_b: Board, agent)\n        return if agent:reachedTarget() then \"success\" else \"running\"\n    end,\n    halt = function(_b: Board, agent)\n        agent:stopMoving()\n    end,\n})\n\nlocal flee = bt.action({\n    enter = function(_b: Board, agent)\n        agent:runAway()\n        return \"running\"\n    end,\n    tick = function(_b: Board, agent)\n        return if agent:isSafe() then \"success\" else \"running\"\n    end,\n    halt = function(_b: Board, agent)\n        agent:stopMoving()\n    end,\n})\n\nlocal investigate = bt.action({\n    enter = function(b: Board, agent)\n        agent:pathTo(b.lastHeard)\n        return \"running\"\n    end,\n    tick = function(b: Board, agent)\n        if agent:reachedTarget() then\n            b.lastHeard = nil\n            return \"success\"\n        end\n        return \"running\"\n    end,\n    halt = function(_b: Board, agent)\n        agent:stopMoving()\n    end,\n})\n\n-- Tree\n\nlocal root = bt.select {\n    bt.sequence {\n        isHurt,\n        bt.select {\n            bt.sequence { hasAllies:invert(), flee },\n            bt.sequence { callForHelp, heal:cooldown(8) },\n        },\n    },\n\n    bt.sequence {\n        hasTarget,\n        canSee,\n        bt.parallel(1) {\n            chase:timeout(6):retry(3),\n            bt.sequence { attack:cooldown(0.8), bt.wait(0.2) } :loop(),\n        },\n    },\n\n    bt.sequence { heardNoise, investigate:timeout(10) },\n\n    bt.random({\n        bt.sequence { patrol, bt.wait(3) } :loop(),\n        bt.wait(5),\n    }, { 3, 1 }),\n\n} :serve(\n    bt.poll(0.3, function(b: Board, agent)\n        b.target = agent:findNearestEnemy()\n        b.canSee = b.target ~= nil and agent:hasLineOfSight(b.target)\n        b.allies = agent:countNearbyAllies()\n    end),\n    bt.poll(1.0, function(b: Board, agent)\n        b.lastHeard = agent:getLastHeardPosition()\n    end)\n)\n\n-- Run\n\nlocal ctx = bt.run(root, board, npc, 10)\n\nnpc.Destroying:Once(function()\n    ctx:destroy()\nend)\n```\n\n## API Reference\n\n### Leaves\n\n| Function | Description |\n|---|---|\n| `bt.check(predicate)` | Boolean gate. Returns `\"success\"` or `\"failure\"`. Predicate receives `(board)`. |\n| `bt.action(handler)` | Function form. Handler receives `(board, agent, dt)`, runs every tick. |\n| `bt.action({ enter, tick, halt })` | Table form. `enter` on first tick, `tick` on subsequent, `halt` on interrupt. At least one of `enter` or `tick` required. `enter` and `tick` receive `(board, agent, dt)`. `halt` receives `(board, agent)`. |\n| `bt.wait(seconds)` | Returns `\"running\"` for N seconds via dt accumulation (simulation time), then `\"success\"`. |\n| `bt.event(signal)` | Returns `\"running\"` until the signal fires once, then `\"success\"`. Connects on entry, disconnects on halt or completion. One-shot per activation. |\n| `bt.poll(interval, updater)` | Fires `updater(board, agent)` on a wall-clock interval (`os.clock`). Always `\"success\"`. |\n\n### Composites\n\n| Function | Description |\n|---|---|\n| `bt.select(children)` | Left to right. Succeeds on first `\"success\"`. Re-evaluates from child 1 every tick. |\n| `bt.sequence(children)` | Left to right. Fails on first `\"failure\"`. Resumes from running child. |\n| `bt.parallel(succeed, fail?)(children)` | Curried. Ticks all children. Resolves by threshold. `fail` defaults to child count. Both thresholds must b","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/axp3cter/roblox/arbor/2.1.0/readme"}