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 |
Virtual machine |
A Python class with a |
Program (bytecode) |
List of instruction dicts loaded from JSON |
Primitives |
Methods on the VM that call Acron APIs ( |
Scripted sequences |
|
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¶
Define an opcode set as string constants or an
Enum.Write a
VMclass. Itsdispatchdict maps opcodes to handler methods that call Acron APIs.Load programs from JSON at runtime. Programs are lists of
{"op": "...", ...}dicts.Run programs inside a
taskMgrtask so they execute across frames (using a program counter andtask.cont).Support a
waitopcode by recording a resume time withglobalClock.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) — neverevaluser-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.