The language subset
GB# compiles a constrained subset of C#. The constraint is not a limitation waiting to be lifted: it is the design. Every construct in the subset lowers predictably to C an 8-bit CPU can run, with no runtime underneath it: no CLR, no JIT, no garbage collector. Anything that would need one is refused at compile time, in GB#'s own words, with an alternative.
This page is the map of that boundary: what is inside, what is outside and why, and the two places where GB#'s syntax deviates from the thesis that motivated it.
What is supported
Types. byte, sbyte, ushort, short, bool, enums, structs, and fixed-size arrays. These are the types the SM83 can work with honestly: 8-bit values are native, 16-bit values cost more and the build says so, and anything wider is refused (GBS0002). int arithmetic that survives into the output is reported as a performance cost (GBS0007): consider ushort if values cannot exceed 65,535, or byte if they cannot exceed 255.
Control flow. if, while, do, for, switch, and the full operator set. while (true) is the canonical GB# game loop.
Structure. ref parameters, static classes, struct methods, properties, and constructors. A struct is a layout and its methods are functions that take a pointer to one: see Structs for what that model buys and what it asks of you.
Data. static readonly arrays are placed in ROM rather than work RAM: see Data in ROM. Array lengths must be compile-time constants (GBS0052), because GB# reserves the storage at compile time.
Samples/Enemies exercises the whole core in one small program that covers structs, enums, fixed collections, arrays, ref parameters, for, switch and 8-bit arithmetic:
public static class EnemySystem
{
public static void Update(ref Enemy enemy, byte frame)
{
switch (enemy.Kind)
{
case EnemyKind.Walker:
enemy.X++;
break;
case EnemyKind.Flyer:
enemy.X++;
// A shift, not a divide: the cost should be obvious from the source.
enemy.Y = (byte)(64 + ((frame >> 3) & 7));
break;
case EnemyKind.Turret:
break;
}
if (enemy.X > 160)
{
enemy.X = 0;
}
}
}
What is refused, and why
Everything on this list needs machinery the Game Boy does not have: a heap, an allocator, an object header, a dispatch table, a scheduler, unwinding. GB# refuses each one by name, pointing at your C#, with the alternative in the message:
Program.cs(12,29): error GBS0042: List<byte> requires dynamic allocation.
public static List<byte> Items = new List<byte>();
^^^^^^^^^^
Use FixedList<T, N> or FixedArray<T, N> instead. Capacity stays visible in the
source, and the storage is reserved at compile time.
| Refused | Id | Why |
|---|---|---|
List<T> and other dynamic collections |
GBS0042 | Requires dynamic allocation. Use FixedList<T> or FixedArray<T>, below. |
System.String |
GBS0043 | Requires heap allocation. Use a fixed byte array, and the tile-based text APIs to draw it: see Drawing text. |
| Exceptions | GBS0044 | There is no unwinding machinery on the target. Return a status value instead. |
| Delegates and events | GBS0045 | Require runtime dispatch. Call the target directly, or switch on an enum to choose between behaviours. |
| Interfaces | GBS0046 | Require virtual dispatch. Use a struct with an enum tag and a switch, which lowers to a jump you can see. |
async / await |
GBS0047 | There is no scheduler on the target. Drive work from the frame loop instead. |
| Boxing | GBS0048 | Boxing puts a value on a heap GB# does not have. Keep the value in its own type. |
| LINQ | GBS0049 | Write the loop. On an 8-bit CPU the loop is what you want to be able to read anyway. |
| Reference type allocation | GBS0050 | new on a class needs a heap. Declare it as a struct, or make the type static if it holds no per-instance state. |
The refusal of delegates has a payoff beyond simplicity: with no function pointers, the call graph is the complete account of what can reach what, which is what makes GB#'s call-depth report exact and its bank-layout advice possible.
One construct is legal but warned about rather than refused: recursion (GBS0058). SM83 has no stack limit check. The stack starts at the top of work RAM and grows down through the same 8 KB the static fields grow up through, so a recursion that goes one level too deep overwrites them: the failure looks like a variable changing value on its own rather than like a crash. Rewriting the recursion as a loop over a FixedList is the usual fix, and a program with recursion in it gets no call-depth report, since the depth is whatever the data makes it.
Fixed collections
FixedArray<T> and FixedList<T> are the subset's answer to List<T>: storage reserved at compile time, at a capacity written in the source. FixedArray<T> is a fixed-length array; FixedList<T> adds a live Count, an Add that returns false when the list is full (there is nowhere to grow into, so the caller decides what that means), a swap-remove RemoveAt, and Clear.
[Capacity(8)]
private static FixedList<Enemy> enemies;
The capacity is compile-time for two reasons. The first is memory: the storage is reserved when the game builds, so the declaration is the complete statement of what the collection costs, and a missing capacity is an error rather than a default (GBS0054; a capacity outside 1–255 is GBS0055). The second is analysis: a FixedList refuses to grow past its capacity, so the capacity is a ceiling the compiler can prove, which is what lets the cycle estimator put a total on the most ordinary loop in a GB# program, for (byte i = 0; i < enemies.Count; i++), even though Count is a runtime field (GBS0410).
Each distinct element type and capacity specialises into its own emitted C struct, so there is no runtime generic machinery and no indirection. The Many objects tutorial builds a game around one.
Two deviations from the thesis
Both are places where the thesis's illustrative syntax is not valid C#. GB# keeps the substance and stays real C#: it will not invent a dialect that only looks like C#.
Sprites[0].X needs using static GB.Hardware;. C# has no static indexers, so Sprites has to be a value rather than a type for indexing to bind. It costs nothing: the handle types erase entirely, and the whole chain compiles to a single OAM store.
FixedList<Enemy, 8> is written [Capacity(8)] static FixedList<Enemy> enemies;. C# has no value type parameters. The capacity still sits at the declaration, in the source, where you can see what it costs.
Where the boundary is enforced
The subset is checked twice with one definition. The Roslyn analyzers report these diagnostics in the editor, before any build, and the compiler reports them again when you build; both read GBSharp.Rules, so an id means the same thing in both places. gbsharp analyze runs the same checks with no C toolchain at all, which makes it the CI lint job: see the CLI reference.