RFC-0011: System Base Classes

Prompt Engineer: Mark Truluck mark@frame-lang.org Status: Implemented Created: 2026-04-18 Updated: 2026-04-20

Implementation status (2026-04-20): Shipped. Parser populates SystemAst.bases: Vec<String> from the : clause on @@system. Each backend’s LanguageSyntax in codegen/backend.rs carries an extends_keyword: Option<String> driving per-language emission (extends, :, <, or None for languages without inheritance). Normative documentation lives in docs/frame_language.md under “Base Classes”.

Problem

Frame systems generate standalone classes with no base class. This blocks scenarios that require inheritance:

  • GDScript: requires extends RefCounted (or similar) for scripts to be loadable via preload().new()
  • Java/C#: framework integration often requires extending a base class or implementing interfaces
  • Python: mixin patterns, ABC enforcement
  • All languages: integration with host frameworks that expect a specific base type

The CodegenNode::Class.base_classes field exists in the codegen AST but is never populated. The infrastructure is ready — the user just has no way to specify base classes.

Syntax

@@system Pong : RefCounted {
    interface:
        get_serve_direction(): int

    machine:
        $Start {
            get_serve_direction(): int { @@:(1) }
        }
}

Multiple bases (for interfaces, traits, protocols):

@@system NetworkPlayer : Node, Serializable {
    ...
}

The : appears after the system name and optional parameters, before {:

@@system Robot($(x: int), name: str) : Node {
    ...
}

Semantics

  • Frame passes base class names through verbatim — no type validation, no class-vs-interface distinction
  • The target language’s compiler enforces inheritance rules (single vs multiple inheritance, class ordering, etc.)
  • Systems without : continue to generate standalone classes (no breaking change)
  • The base class list is stored in SystemAst.bases: Vec<String> and flows into CodegenNode::Class.base_classes

Per-Language Rendering

Target @@system Foo : A, B
Python class Foo(A, B):
GDScript extends A (file-level) + module-scope emission
TypeScript class Foo extends A implements B {
JavaScript class Foo extends A { (no interfaces)
Java class Foo extends A implements B {
Kotlin class Foo : A(), B {
C# class Foo : A, B {
C++ class Foo : public A, public B {
Swift class Foo: A, B {
Go embed A as field, no syntax for multiple
Dart class Foo extends A implements B {
Rust no class inheritance; generates impl B for Foo stubs
C no class inheritance; ignored
PHP class Foo extends A implements B {
Ruby class Foo < A (single inheritance)
Lua metatable-based, first base sets metatable
Erlang ignored (gen_statem is functional)

GDScript Special Behavior

When bases is non-empty and target is GDScript, the backend emits the system at module scope instead of as an inner class:

# With @@system Pong : RefCounted
extends RefCounted

class PongFrameEvent:
    ...

# System fields and methods at module scope (no class Pong: wrapper)
var _state_stack
var __compartment
...

func get_serve_direction() -> int:
    ...

This matches GDScript’s one-class-per-file convention and enables preload("res://pong.gd").new().

When bases is empty, the existing inner-class pattern is preserved for backward compatibility.

Implementation

Parser (pipeline_parser/mod.rs)

After parsing @@system Name(params), check for Token::Colon. If present, parse comma-separated type names until Token::LBrace:

@@system Name (params)? (: Base1, Base2, ...)? { ... }

AST (frame_ast.rs)

Add bases: Vec<String> to SystemAst.

Codegen (system_codegen.rs)

Populate CodegenNode::Class.base_classes from system.bases.

Backends

Each backend’s CodegenNode::Class rendering uses base_classes to emit the language-appropriate inheritance syntax. Most backends already have the rendering logic (currently dead code) — they just need the data.