Runtime System
The runtime (@gi-tcg/gts-runtime) provides the execution model for transpiled GTS code. It defines the ViewModel pattern that processes define statements at runtime.
Overview
When a .gts file is transpiled and executed, the generated JavaScript calls runtime functions to:
- Create attribute node trees from GTS definitions
- Parse those trees through ViewModels (which execute the game logic)
- Extract binding values (exported variables from
asclauses)
Exports
// src/index.ts
export { defineViewModel, type AttributeDefinition, type IViewModel, type AttributeReturn } from "./view_model";
export { Action, Prelude, Meta, NamedDefinition } from "./symbols";
export { createBinding, createDefine } from "./view";Symbols (src/symbols.ts)
GTS uses unique symbols as special property keys:
| Symbol | Purpose |
|---|---|
Meta | Carries accumulated type-level metadata through attribute chains |
Action | Identifies the direct action handler in a named attribute block |
NamedDefinition | Stores the ViewModel's block definition type (attribute schema) |
Prelude | Provides shortcut function context (element constants, etc.) |
export const Meta: unique symbol = Symbol("Meta");
export const Action: unique symbol = Symbol("Action");
export const NamedDefinition: unique symbol = Symbol("NamedDefinition");
export const Prelude: unique symbol = Symbol("Prelude");These symbols serve dual purposes:
- At runtime — used as property keys on objects (e.g.,
ctx[Prelude]to access element constants) - At type level — used as index types for type-level computation (e.g.,
VM[NamedDefinition]to get the attribute type schema)
View System (src/view.ts)
Data Structures
SingleAttributeNode — represents one attribute call:
interface SingleAttributeNode {
name: string | symbol; // attribute name (or Action symbol)
positionals: () => any[]; // lazy positional arguments
named: NamedAttributesNode | null; // nested attributes
binding?: "public" | "private"; // binding export marker
}NamedAttributesNode — a collection of attributes:
interface NamedAttributesNode {
attributes: SingleAttributeNode[];
}View<BlockDef> — wraps a NamedAttributesNode with an optional BindingContext:
class View<BlockDef extends AttributeBlockDefinition> {
constructor(
public _node: NamedAttributesNode,
public _bindingCtx?: BindingContext | undefined,
) {}
}BindingContext — collects binding values during a createBinding call:
class BindingContext {
addBinding(value: unknown): void;
getBindings(): unknown[];
}Entry Points
createDefine(rootVM, node) — executes a define statement (fire-and-forget):
function createDefine(rootVM: ViewModel<any, any>, node: SingleAttributeNode): void {
const view = new View<any>({ attributes: [node] });
rootVM.parse(view);
}createBinding(rootVM, node) — executes a define and returns binding values:
function createBinding(rootVM: ViewModel<any, any>, node: SingleAttributeNode): unknown[] {
const bindingCtx = new BindingContext();
const view = new View<any>({ attributes: [node] }, bindingCtx);
rootVM.parse(view);
return bindingCtx.getBindings();
}ViewModel (src/view_model.ts)
ViewModel Class
The ViewModel<ModelT, BlockDef> is the central execution unit:
class ViewModel<ModelT, BlockDef extends AttributeBlockDefinition> {
constructor(private Ctor: new () => ModelT) {}
parse(view: View<...>): ModelT {
const model = new this.Ctor();
for (const attrNode of view._node.attributes) {
// Look up registered action (or binder if inside binding context)
// Call the action with (model, positionals, namedView)
// If binding flag set, collect the return value
}
return model;
}
}Execution flow:
- Instantiate the model class (
new Ctor()) - Iterate over attribute nodes
- For each attribute, look up the registered action (or binder) by name
- Call the action with
(model, positionals, new View(named, bindingCtx)) - If the attribute has a
bindingflag and we're in a binding context, collect the return value - Return the built model
Action vs. Binder:
- Actions are used during
createDefine— they execute the game logic (e.g., set properties on the builder model) - Binders are used during
createBinding— they compute the exported value (e.g., return a handle/ID)
defineViewModel
function defineViewModel<T, BlockDef, InitMeta>(
Ctor: new () => T,
modelDefFn: (helper: AttributeDefHelper<T>) => BlockDef,
initMeta?: InitMeta,
): ViewModel<T, BlockDef & { [Meta]: InitMeta }>;Usage (from examples/provider/vm.ts):
const CharacterVM = defineViewModel(
CharacterBuilder,
(helper) => ({
id: helper.attribute<{
(id: number): AR.Done;
required(): true;
as<TMeta>(this: AR.This<TMeta>): CharacterHandle<TMeta["varNames"]>;
}>(
(model, pos) => { /* action: set ID on model */ },
(_, [id]) => id as CharacterHandle<any>, // binder: return handle
),
since: helper.simpleAttribute(function (version: "v3.3.0" | "v3.4.0") {
this.setVersion(version);
}),
tags: helper.simpleAttribute(function (...tags: Tag[]) {}),
health: helper.simpleAttribute(function (value: number) {}),
energy: helper.simpleAttribute(function (value: number) {}),
skills: helper.attribute<{
(...handles: CharacterSkillHandle[]): AR.Done;
}>(() => {}),
}),
{} as { varNames: never },
);AttributeDefHelper
The helper provides two methods for defining attributes:
attribute<T>(action, binder?) — full control over action and binder:
action(model, positionals, namedView)— called during definebindercan be:- A function
(model, positionals, namedView) => value— custom binder - A
ViewModel— automatically callsvm.parse(namedView)as the binder - Omitted — no-op binder
- A function
simpleAttribute(action, binder?) — convenience wrapper:
actionreceivesthis: ModelTand spread positional argsbinderreceivesthis: ModelTand spread positional args, returns the binding value
AttributeReturn Types
The AttributeReturn (aliased as AR) namespace provides return type utilities:
| Type | Description |
|---|---|
AR.Done | Attribute has no nested block and doesn't rewrite meta |
AR.This<TMeta> | Access the current meta type (for this parameter) |
AR.EnableIf<Cond, T> | Conditional type helper |
AR.With<VM, TMeta> | Attribute opens a nested ViewModel block |
AR.DoneRewriteMeta<NewMeta> | Attribute rewrites the meta type |
AR.WithRewriteMeta<VM, NewMeta> | Opens nested VM and rewrites meta |
Meta rewriting is how GTS tracks accumulated state through attribute chains. For example, the variable attribute adds a variable name to the meta:
variable: helper.attribute<{
<TMeta extends BuilderMeta, const TVarName extends string>(
this: AR.This<TMeta>,
variable: TVarName,
initialValue: number,
): AR.WithRewriteMeta<typeof VariableVM, {
varNames: TMeta["varNames"] | TVarName;
}>;
}>(() => {});After variable "foo", 42;, the meta type changes from { varNames: never } to { varNames: "foo" }, enabling type-safe access to the variable later.
Transpilation Output Example
Given this GTS source:
define character {
id 1201 as Barbara;
health 10;
}The transpiler generates:
import { createDefine, createBinding, Action, Prelude } from "@gi-tcg/gts-runtime";
import __gts_rootVm from "@example/provider/vm";
const __gts_node_0 = {
name: "character",
positionals: () => [],
named: {
attributes: [
{ name: "id", positionals: () => [1201], named: null, binding: "public" },
{ name: "health", positionals: () => [10], named: null },
]
}
};
const __gts_bindings_0 = createBinding(__gts_rootVm, __gts_node_0);
export const Barbara = __gts_bindings_0[0];
createDefine(__gts_rootVm, __gts_node_0);At runtime:
createBindinginstantiates aRootBuilder, finds thecharacteraction, which creates aCharacterVMand parses the nested attributes- The
idattribute's binder returns1201 as CharacterHandle, which becomesBarbara createDefinere-runs the same process (for side effects like registration)
Provider Pattern
The runtime is designed to be used with a provider — a separate package that defines the ViewModels for a specific game domain. The provider exports:
./vm— the root ViewModel (default export)./query— the query function (default export)./runtime— re-exports from@gi-tcg/gts-runtime
This separation allows GTS files to be written against a stable language interface while the game implementation evolves independently.