Facepunch.Breen
A general-purpose, embeddable scripting language for .NET with C# syntax, compiled execution, low-allocation runtime paths, and host-controlled access.
Install
| Product | Versions |
|---|---|
| .NET |
README
Breen
Breen is a scripting language for C# applications. It looks and works like C#, because that's the idea.
In our quick tests, warmed Breen was around 1.5–2.3× faster than native Luau across five small scripts. More on that below.
If this looks like it was coded by AI, that's because it mostly was.
Script Syntax
int reward = Math.Clamp(Kills * 100, 0, 10_000);
if (IsWinner)
{
reward *= 2;
}
Player.AddScore(reward);
Player.LastReward = DateTime.UtcNow;
return Player.Score;
Yeah, it's C#. What did you expect?
Math and DateTime are the usual classes from System. Player is an instance of your own C# class, supplied by your application alongside Kills and IsWinner. Its properties and methods work the same way. No wrapper class or Main method needed. We don't support all of C# yet.
Script System
Use the Facepunch.Breen package with .NET 10.
Reference: hosting and execution, editor integration, and language support and limitations.
using Breen;
ScriptSystemOptions options = new()
{
// Reflection, unsandboxed. Use your own IResolver to choose
// which types and members scripts can access.
Resolver = new DefaultResolver()
};
// Keep the system around. It caches parsed source, bindings and compiled code.
ScriptSystem system = new(options);
// Run a one-off script and get its return value directly.
float value = system.RunScript<float>("return 32 * 100f;"); // 3200
// Or keep a script and run it repeatedly with different inputs.
Script script = system.CreateScript("return Kills * 100f;");
script.Set("Kills", 32);
// Run compiles automatically on first use. To move compilation to load time,
// set the inputs and call script.Compile() before the first Run.
RunResult result = script.Run();
if (!result.Success)
{
throw new InvalidOperationException(result.Error!.Message, result.Exception);
}
float score = script.ReturnValue.Get<float>(); // 3200
script.Set("Kills", 64);
result = script.Run();
if (!result.Success)
{
throw new InvalidOperationException(result.Error!.Message, result.Exception);
}
score = script.ReturnValue.Get<float>(); // 6400
The system keeps a bounded cache of source and compiled plans, so creating the same script again can reuse the work. Each Script has its own inputs and execution state. Execution requires successful IL compilation. Repeated runs reuse compiled code, and observed types can enable further specialization. Use a system and its scripts sequentially on one thread.
DefaultResolver lets scripts call public APIs with your process's permissions. To control access, implement IResolver.ResolveType and GetMembers(Type, List<MemberInfo>); Breen handles binding and overload selection.
system.AnalyzeEditor(document) discovers inputs without requiring their runtime values or types. Inputs without type metadata remain unknown during analysis; supplying EditorInput metadata enables type checking and member completion. Known type errors are still reported. Runtime execution continues to require actual input values through Script.Set.
Mutating struct methods update writable locals, inputs, host fields, and array elements. Property results, readonly fields, and foreach variables use defensive copies as in C#. Inputs and persistent fields publish their updated value when the method exits, including exceptional exits; ordinary CLR locals, fields, and array elements mutate in place.
Scripts can construct and use closed generic types such as List<int> and Dictionary<string, int>, including nested type arguments, explicit declarations, casts, and defaults. A custom resolver approves the open CLR name (List followed by a backtick and 1, for example) and each non-primitive argument type separately. Generic constraints are checked before execution, and constructors and members still require permission through the member catalog.
Scripts can also call host generic methods with explicit type arguments, such as Entity.GetComponent<Health>(), Factory.Create<Item>(), and Host.Echo<List<int>>(values). Instance, static, and null-conditional calls use the same compiled execution and overload selection as ordinary calls. The resolver must expose the generic method definition and approve each explicit non-primitive type argument; generic constraints are checked before calling the method. Binding invalidation rechecks these permissions. Custom IBindingResolver implementations receive the explicit types in OperationRequest.GenericTypeArguments.
Generic method arguments can also be inferred from supplied values: Host.Echo(7) selects Echo<int>, and a host method First<T>(IEnumerable<T> values) can infer int from a List<int> or int[] argument. Inference matches nested generic types, arrays, base classes, interfaces, and variance, using Breen's existing argument conversions. Generic Add<T> methods also work in collection initializers. Each applicable overload is inferred and constraint-checked separately; equally matching ordinary methods take precedence over generic methods. Missing or conflicting bounds and ambiguous overloads report binding errors. Null literals, return types, and constraints cannot supply missing type arguments, so GetComponent<T>() still requires an explicit type.
Inferred types come from the argument types already available to binding, including concrete runtime types at dynamic call sites; they do not require a script-visible type name. The approved member catalog controls which generic definitions may be used. Compilation and editor analysis never evaluate host expressions to infer types. Existing binding caches, permission invalidation, and specialization guards also cover inferred calls, with no inference work or additional allocations on warmed calls. Extension-method syntax, named arguments, and reference parameters are not supported.
Calls can omit trailing optional arguments. For a host method Spawn(string prefab, int count = 1, bool enabled = true), scripts can call World.Spawn("crate") or World.Spawn("crate", 3). Constructors, indexers, delegate Invoke calls, and collection initializer Add calls use the same metadata defaults. Generic inference uses only supplied arguments; omitted defaults do not infer missing type arguments. Required arguments still need values, and ambiguous overloads report binding errors.
Defaults are embedded in the cached call binding without evaluating host code during compilation or editor analysis. Primitive, string, enum, decimal, nullable, and default(T) values are supported, including closed generic parameters. Default structs are zero-initialized without invoking their constructors. Omitted arguments preserve source evaluation order, execution budgets, permission invalidation, and instance isolation. Warmed calls add no allocation overhead for unboxed defaults; defaults requiring boxing create fresh boxes as in C#.
Array-based params arguments can be expanded: for Sum(params int[] values), scripts can call Host.Sum(1, 2, 3), Host.Sum(1), or Host.Sum(). Explicit arrays remain valid and retain their identity; a compatible array or null uses the normal array form before considering expansion. Constructors, indexers, delegate Invoke, and collection initializer Add calls also support expansion. Generic methods infer types from either the supplied array or the expanded elements, and optional parameters before the array can be omitted when no later arguments are supplied.
Each nonempty expansion creates a fresh array, with numeric and reference conversions and boxing where required, including params object[]. Empty expansions reuse an empty array and add no warmed allocation. Binding preserves argument evaluation order, execution budgets, resolver permissions, bounded caches, and specialization guards. Array expansion is compiled into the cached call binding; it does not run during compilation or editor analysis. Non-array parameter collections such as params ReadOnlySpan<T> and params IEnumerable<T> are not expanded.
Array creation supports sizes (new int[10]), typed and inferred initializers (new int[] { 1, 2 }, new[] { 1, 2 }), and declaration initializers (int[] values = { 1, 2 };). Jagged and rectangular arrays are supported, including indexed reads and writes. Each evaluation creates a fresh array and evaluates its dimensions and elements in source order.
Collections implementing IEnumerable support Add initializers, such as new List<int> { 1, 2 } and new Dictionary<string, int> { { "score", 10 } }. Nested member initializers, target-typed elements, constant conversions, and mutable struct collections use the same approved member catalog. Initializers call Add in source order and publish the collection only after initialization succeeds.
foreach handles dictionary entries and custom structs, including structs containing references or larger than the inline value storage. Generic enumerators avoid boxing each element. Dynamic and non-generic traversal copies struct values so later enumerator changes cannot modify a saved value. Enumerators are disposed on completion, early returns, failures, and budget exhaustion. Host inputs supplied through Value.From or Script.Set use the same value storage: small reference-free structs stay inline; other structs allocate immutable managed snapshots.
Top-level fields accept CLR attributes, for example [System.Obsolete("metadata only")] int value = 7;. Attribute names use the same resolver as other types, including the Attribute suffix convention, qualified names, and closed generic types. The compiler checks AttributeUsage, permitted constructors and writable named members, constant arguments, typeof, nameof for script fields or permitted type members, and attribute array initializers. Attribute constructors, setters, and static initializers never run. Ordinary annotations have no runtime effects: fields still initialize on each invocation, and attributes such as Obsolete do not change field-use diagnostics. Store markers described below opt fields into persistence. Attributes on methods and ordinary block-local variables are not supported; [Global], [Local], and [Init] are not built-in attributes or lifecycle features.
Hosts can mark their own field attribute classes with Breen.StoreAttribute:
// Host application
[Breen.Store("global")]
[AttributeUsage(AttributeTargets.Field)]
public sealed class GlobalAttribute : Attribute { }
// Script source, using the host's resolver
[Global]
float Gravity = 720.0f;
The compiler records each annotated field's name, local slot, source span, and store name in its internal prepared metadata. Store names are case-sensitive, preserved exactly, and cannot be null, empty, or whitespace-only. A derived attribute inherits the nearest store marker unless it declares its own. More than one storage attribute on a field is an error, even when the attributes name the same store. Ordinary attributes can accompany a storage attribute. These checks also appear in editor diagnostics.
The host field attribute still needs the usual type and member permissions. Its store marker is read by exact CLR identity using CustomAttributeData, like AttributeUsage; the resolver does not need to expose Breen.StoreAttribute itself. Metadata is refreshed after binding invalidation without executing attribute code.
Attach host-owned stores to a system or an individual script:
var global = new ScriptStore();
system.SetStore("global", global);
script.SetStore("local", entity.GetScriptStore());
// Optional host access: T must match the entry's declared type.
global.Set("Gravity", 720.0f);
float gravity = global.Get<float>("Gravity");
bool exists = global.TryGet<float>("Gravity", out gravity);
A script attachment overrides the system attachment with the same name. Sharing a ScriptStore shares its values, including across systems. CreateInstance() shares compiled code, sees system stores, and starts without instance store attachments. Attachments can be replaced between runs; existing entries in the newly attached store are reused.
Fields use their unescaped name as the entry key. Declarations execute in source order: an absent or uninitialized entry evaluates its initializer once, or uses the declared type's default when there is no initializer. An existing entry skips the initializer and its instruction cost. Successful null/default values count as initialized. Reads and writes use the shared entry immediately, so host changes and other scripts' writes are visible within the current invocation. Ordinary variables remain fresh each run.
Compilation and editor analysis validate initializers without running them or requiring stores to be attached. Execution reports SCR_STORE_MISSING for a reached declaration without its store and SCR_STORE_TYPE for an incompatible declared CLR type. Compatibility uses the exact declared type, not the current value's runtime subtype; no automatic type migration is performed. For a var field whose type is only known at runtime, the first successful initialization establishes that type; specialization cannot assume a skipped initializer ran. Recreating scripts after compatible source edits preserves state when the same stores and field names are reused. Cache clearing and binding invalidation preserve store data and recheck permissions even when an initializer would be skipped.
An initializer that throws or exhausts its budget leaves its entry uninitialized and retryable. Assignments to that entry during initialization are discarded on failure. Completed writes to other entries and host side effects remain. Recursive initialization through another script reports SCR_STORE_INITIALIZATION; host reads cannot see an unfinished entry. Stores, like script instances, are intended for sequential use on the scripting thread. Method hooks and automatic reset/migration policies are not implemented.
Attribute validation is repeated after InvalidateBindings() and is shared with editor diagnostics. Attribute argument types and referenced constant members also require resolver permission. Validation adds no execution instructions or warmed allocations.
Speed
The first run pays for binding and compilation. Warmed runs reuse that work. Our small reflection benchmark took roughly 3 ms to create and compile, then roughly 13 ns per warmed run, allocating nothing during those warmed calls. Setup matters; keep scripts around when you're calling them often.
Observed-type specialization can also compile during Run, including after binding invalidation. Inside exception scopes, budget checks preserve each instruction's effects and observed-type specialization is disabled. Outside those scopes, grouped budget checks and specialization remain available. Exception scripts still need runtime budget accounting and CLR unwinding, so benchmark representative workloads. See the execution contract for fault and cleanup behavior.
The 1.5–2.3× Luau comparison is from five small warmed scripts, with one protected host call per evaluation and native Luau code generation enabled. It's a quick comparison, not a promise for every script.
Adoption Assessment
Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.
Related Guides & Workflows
Learn how to evaluate, integrate, or audit this package in your .NET project.