Download Game! Currently 51 players and visitors. Last logged in:PanthosMesardusCilwandValkrist

Blitzer's Blog >> 72227

Back to blogs index
Posted: 10 Sep 2026 06:06 [ permalink ]
To implement a let block that provides true lexical block scoping (like let
and const in JavaScript, rather than function-wide var scoping), we can
seamlessly reuse the prototype chain mechanism we already established for
closures.
Instead of polluting the current function's local environment, a let block
should push a temporary environment onto the prototype chain, STORE the new
variables there, execute its body, and then pop that temporary environment off
before continuing.
1. Designing the AST Structure
A standard Lisp-style let takes a list of bindings (name-value pairs) followed
by the body to evaluate.
[
  "let", 
  [
    ["x", 10], 
    ["y", ["*", 2, 5]]
  ], 
  ["+", "x", "y"]
]

2. Expanding the Instruction Set
We need two new opcodes to manage the block scope lifecycle:
const Opcodes = Object.assign(Opcodes || {}, {
  ENTER_SCOPE: 'OP_ENTER_SCOPE', // Pushes a new environment onto the
prototype chain
  EXIT_SCOPE:  'OP_EXIT_SCOPE'   // Pops the current environment, reverting to
the parent
});

3. Adapting the Compiler
When the compiler encounters a let block, it wraps the entire operation in the
scope opcodes. It compiles the initialization of each variable to leave the
value on the data stack, immediately followed by a STORE opcode.
function compile(ast, bytecode = []) {
  // ... primitive cases ...
  const [op, ...args] = ast;

  if (op === 'let') {
    const [bindings, body] = args;
    
    // 1. Create a new block scope
    bytecode.push({ op: Opcodes.ENTER_SCOPE });
    
    // 2. Compile each binding using the STORE opcode
    for (const [varName, valueExpr] of bindings) {
      compile(valueExpr, bytecode);                     // Pushes value to
data stack
      bytecode.push({ op: Opcodes.STORE, val: varName }); // Pops to current
frame.env
    }
    
    // 3. Compile the body of the let block
    compile(body, bytecode); // Leaves the final result on the data stack
    
    // 4. Destroy the block scope
    bytecode.push({ op: Opcodes.EXIT_SCOPE });
    
    return bytecode;
  }
  
  // ... def, set, if, CALL, etc. ...
}

4. Adapting the VM Execution Loop
Because STORE is already designed to write directly to frame.env, we don't
need to change the STORE opcode at all. We just need to mutate frame.env when
we enter and exit the block.
      case Opcodes.ENTER_SCOPE:
        // Create a new empty object that inherits from the current
environment
        frame.env = Object.create(frame.env);
        break;

      case Opcodes.EXIT_SCOPE:
        // Revert to the parent environment, abandoning the block variables
        frame.env = Object.getPrototypeOf(frame.env);
        break;

      case Opcodes.STORE:
        // Remains completely unchanged. It writes to the active frame.env, 
        // which might be a block scope or a function scope.
        frame.env[inst.val] = dataStack.pop();
        break;

Execution Flow and the Data Stack
The beauty of a stack machine is how it naturally handles the return values of
blocks. Let's trace the data stack for our example: ["let", [["x", 10]], "x"]
 * OP_ENTER_SCOPE: frame.env becomes { __proto__: parentEnv }. Data Stack: []
 * OP_CONST (10): Data Stack: [10]
 * OP_STORE ("x"): Pops 10. frame.env becomes { x: 10, __proto__: parentEnv }.
Data Stack: []
 * OP_LOAD ("x"): Pushes 10. Data Stack: [10]
 * OP_EXIT_SCOPE: frame.env reverts to parentEnv. Data Stack: [10]
The block variables are garbage collected, but the calculated result (10)
survives securely on the top of the data stack, ready to be consumed by
whatever operation encloses the let block.