Bytecode

Intent: Give behavior the flexibility of data by encoding it as instructions for a virtual machine.

Acron Mapping

Pattern role

Acron equivalent

Instruction set

Python enum or string constants for opcodes

Virtual machine

A Python class with a run(program) method and a dispatch dict

Program (bytecode)

List of instruction dicts loaded from JSON

Primitives

Methods on the VM that call Acron APIs (_play_sfx, _spawn_node, _wait)

Scripted sequences

Sequence + FunctionInterval — a simpler built-in alternative for linear scripts

When to use full bytecode vs. Intervals: Use Sequence / FunctionInterval for linear timed scripts (cutscenes, tutorials). Use a bytecode VM when designers need branching, loops, and variables that Intervals can’t express.

Why bytecode over the Interpreter pattern: The book contrasts Bytecode with the GoF Interpreter pattern. The Interpreter pattern encodes behavior as a tree of objects — expressive, but each node is a heap allocation and tree traversal has poor cache locality. Bytecode uses a flat array of instructions instead: compact, cache-friendly, and fast to execute. The trade-off is that a bytecode VM is more work to build and debug, so only commit to it when runtime-editable, branching behavior is genuinely needed.

How to Implement

  1. Define an opcode set as string constants or an Enum.

  2. Write a VM class. Its dispatch dict maps opcodes to handler methods that call Acron APIs.

  3. Load programs from JSON at runtime. Programs are lists of {"op": "...", ...} dicts.

  4. Run programs inside a taskMgr task so they execute across frames (using a program counter and task.cont).

  5. Support a wait opcode by recording a resume time with globalClock.getFrameTime().

Code Sketch (Python)

import json

class SpellVM:
    def __init__(self, caster_node):
        self._caster = caster_node
        self._pc = 0
        self._program = []
        self._wait_until = 0.0
        self._dispatch = {
            'play_sfx':    self._op_play_sfx,
            'spawn_fx':    self._op_spawn_fx,
            'deal_damage': self._op_deal_damage,
            'wait':        self._op_wait,
        }

    def load(self, json_path: str):
        with open(json_path) as f:
            self._program = json.load(f)
        self._pc = 0
        taskMgr.add(self._step, f'spellVM_{id(self)}')

    def _step(self, task):
        now = globalClock.getFrameTime()
        if now < self._wait_until:
            return task.cont
        if self._pc >= len(self._program):
            return task.done
        instr = self._program[self._pc]
        self._pc += 1
        self._dispatch[instr['op']](instr)
        return task.cont

    def _op_play_sfx(self, instr):
        loader.loadSfx(instr['path']).play()

    def _op_spawn_fx(self, instr):
        fx = loader.loadModel(instr['model'])
        fx.reparentTo(render)
        fx.setPos(self._caster.getPos())

    def _op_deal_damage(self, instr):
        messenger.send('damage', [self._caster, instr['amount']])

    def _op_wait(self, instr):
        self._wait_until = globalClock.getFrameTime() + instr['seconds']

Example spells/fireball.json:

[
  {"op": "play_sfx",    "path": "sfx/fireball_cast.ogg"},
  {"op": "spawn_fx",    "model": "fx/fireball.bam"},
  {"op": "wait",        "seconds": 0.4},
  {"op": "deal_damage", "amount": 50}
]

Pitfalls

  • Security: eval() / exec() of untrusted scripts is a code injection vector. Use a dispatch table (shown above) — never eval user-supplied strings.

  • Infinite loops in scripts: a buggy script with a loop opcode can stall a task. Add a max-instructions-per-frame guard.

  • Complexity vs. Intervals: if designers only need linear sequences with timings, Sequence([FunctionInterval(fn), Wait(t), ...]) is simpler and already frame-safe. Use bytecode only when branching logic is needed.