Write Bevy games in C#.
BevyCSharp.Sample, running on Bevy's PBR renderer through the bridge:
dotnet run --project BevyCSharp.Sample
Mark a struct [Behavior], give it methods with stage attributes, and a Roslyn source generator
wires it into Bevy's schedule, as a component and a system at the same time. Bevy is the engine
underneath, with its ECS, its scheduler, its timing, its input and its renderer.
using Bevy;
[Behavior]
public partial struct Bouncer
{
public float Height;
public float Velocity;
[OnStartup]
public static void Spawn(BehaviorContext ctx) =>
ctx.Ecs.Add(ctx.Ecs.Spawn(), new Bouncer { Height = 5f });
[OnUpdate]
public void Tick(BehaviorContext ctx)
{
Velocity -= 9.81f * ctx.Time.Delta;
Height += Velocity * ctx.Time.Delta;
if (Height > 0f) return;
Height = 0f;
Velocity = -Velocity * 0.8f;
}
}In Program.cs
BevyApp.Run();Behaviors are discovered automatically, so a consuming project needs no registration code.
- Install, and what the package carries.
- Behaviors: what a game writes. Systems and components, stages, the fixed timestep, filters, conditions, states, messages, the hierarchy, and what may touch the world from a worker.
- The engine: what a behavior can reach. Bevy's own components, assets and models, drawing, 2D, gizmos, the interface, audio and input.
- Running a game: in a window, with no renderer, or into an image, and reloading behavior scripts while it runs.
- The tools: the editor, its console, and driving a running app from a terminal.
- How it works inside, what is still missing, and building from source.
dotnet add package BevyCSharp
The package carries three things: the managed library, the source generator (in the analyzer
slot), and a prebuilt native bridge per runtime identifier under runtimes/.
A [Behavior] struct is both a component and the systems that act on it. This chapter is what a
game writes, which is what a method runs as, when it runs, which entities it sees, and what it
may touch while it does.
Which one a method is depends on whether it is static.
Static methods are plain systems. They run once per frame. Use them for global logic that queries other components.
[Behavior]
public partial struct Gravity
{
[OnUpdate]
public static void Apply(BehaviorContext ctx)
{
foreach (var row in ctx.Ecs.Query<Velocity>())
row.Component.Y -= 9.81f * ctx.Time.Delta;
}
}Query yields references into Bevy's table storage, so assigning to row.Component writes the
real component. There is no copy and no write-back step.
Instance methods run per entity. this is bound by reference to that entity's component. The struct's fields are per-entity state
living in Bevy's tables.
[Behavior]
public partial struct Spinner
{
public float Angle;
public float Speed;
[OnUpdate]
public void Tick(BehaviorContext ctx) =>
Angle += Speed * ctx.Time.Delta;
}Above ~4096 entities the per-entity loop is automatically split across the thread pool.
Any blittable struct is a component. Any blittable struct is a component. It needs no attribute and no interface, the first time a
behavior touches it, its layout is registered with Bevy and it becomes a real Bevy component
with a real ComponentId.
public struct Position { public float X, Y; }
public struct Falls; // a zero-field tag costs nothing to storeComponents sit in contiguous columns, which is what makes iteration fast. The cost is paid on
insertion and removal, because both move the entity to another archetype and copy its other
components along with it. A tag that is added and removed far more often than it is read can opt out of that
trade by implementing ISparseComponent:
public struct Colliding : ISparseComponent; // toggled every frame, only ever filtered onAdding or removing one costs an index write and moves nothing else. In exchange it cannot be the
component a query iterates: Bevy exposes no way to reach a sparse set's storage in bulk, so
Query<Colliding>() is refused rather than quietly returning nothing. Everything else works,
including the thing it is for:
[OnUpdate]
[Without(typeof(Colliding))]
public void Fall(BehaviorContext ctx) { }A sparse filter cannot be answered once per table, because two entities in one may differ, so it is answered per entity. The same rows come back, split into the contiguous runs that satisfy it.
| Attribute | When |
|---|---|
[OnStartup] |
Once, before the first frame |
[OnFirst] |
Top of every frame |
[OnPreUpdate] |
Before Update |
[OnFixedUpdate] |
Fixed timestep: zero or more times a frame |
[OnUpdate] |
Main gameplay stage |
[OnPostUpdate] |
After Update, before queued commands are applied |
[OnRender] |
Drawing and overlays, ordered before Last |
[OnLast] |
End of every frame |
[OnCleanup] |
Once, on the way out |
Every stage above except one runs exactly once a frame, so anything integrated in them advances by however long the frame happened to take. That ties the result to the machine, because the same inputs give a different fall on a slow frame, and a long enough one steps straight through the floor.
[OnFixedUpdate] runs on Bevy's fixed timestep instead, as many times per frame as the elapsed
time allows: twice after a slow frame, not at all after a fast one. Each run covers the same
slice of time, so the simulation is reproducible.
[OnFixedUpdate]
public void Step(BehaviorContext ctx)
{
Velocity.Y -= 9.81f * ctx.Time.FixedDelta;
}Integrate with ctx.Time.FixedDelta, not ctx.Time.Delta. It is the constant each step covers
rather than a per-frame reading, so it is correct from the first frame and identical in every
step. The rate is Config.FixedHz and defaults to Bevy's 64.
Keep a simulation on one clock or the other. Accelerating on the fixed step while integrating position per frame is half a simulation, and inherits the frame-rate dependence you moved the other half away from.
[With] and [Without] restrict an instance method to a subset of entities. They are resolved
per archetype, not per entity, so they cost nothing in the loop.
[OnUpdate]
[With(typeof(Alive))]
[Without(typeof(Frozen))]
public void Tick(BehaviorContext ctx) { }[Changed] skips entities whose listed components did not change this frame. It is a per-entity
test against Bevy's change ticks, so a method carrying it runs sequentially.
[RunIf] gates a system on a static bool member of the same struct, a field, a property, or a
method taking a World. The generator checks the member exists at compile time, so a rename
cannot silently disable your system.
[OnUpdate]
[RunIf(nameof(IsPlaying))]
public static void Tick(BehaviorContext ctx) { }
public static bool IsPlaying(World world) =>
world.TryGetResource<GameState>(out var s) && s.Playing;[ToggleKey] is the entire implementation of "press F3 to show the overlay":
[OnRender]
[ToggleKey(Key.F3, DefaultEnabled = false)]
public static void DrawHud(BehaviorContext ctx) { }KeyModifier is a flags enum, so a shortcut can require any number of modifiers at once:
[ToggleKey(Key.F3, KeyModifier.Ctrl)] // Ctrl + F3
[ToggleKey(Key.F3, KeyModifier.Ctrl | KeyModifier.Shift)] // Ctrl + Shift + F3Each flag is side-agnostic, so Ctrl is satisfied by either Ctrl key, which is what a shortcut
normally means, and matches winit's ModifiersState, the layer Bevy's own windowing sits on.
Bevy itself has no modifier type; it exposes only the individual KeyCodes. To pin one side, or
to build a chord out of an ordinary key, write the check yourself:
[OnRender]
[RunIf(nameof(ChordHeld))]
public static void DrawHud(BehaviorContext ctx) { }
public static bool ChordHeld(World world) =>
world.Resource<Input>().AllKeysDown([Key.ControlLeft, Key.F3]);Input mirrors Bevy's ButtonInput here: AnyKeyDown, AllKeysDown, AnyKeyPressed and
AnyKeyReleased take a span of keys, like any_pressed / all_pressed / any_just_pressed.
A game is usually in one of a few modes, and most systems belong to one of them. AddState sets
one up over any enum, and [InState] scopes a method to a value of it:
public enum Screen { Menu, Playing, Paused }
app.AddState(Screen.Menu);
[OnUpdate]
[InState(Screen.Playing)]
public void Tick(BehaviorContext ctx) { }Read and change it from a system:
var screen = ctx.State<Screen>();
ctx.SetState(Screen.Paused);[InState] runs a method every frame the state is held. To run one as the state changes, on
the edge rather than throughout, use [OnEnter] and [OnExit]:
[OnEnter(Screen.Playing)]
public static void BuildLevel(BehaviorContext ctx) { }
[OnExit(Screen.Playing)]
public static void TearDown(BehaviorContext ctx) { }That is where a screen is built and taken away: once per transition, not once per frame. A transition attribute replaces the stage attribute rather than joining it, because the two say different things about when a method runs, and asking for both is reported as an error.
A teardown method that lists everything the screen spawned goes stale the first time something new is added to the screen. Tie the entity to the state instead and leaving takes it with you:
[OnEnter(Screen.Playing)]
public static void BuildLevel(BehaviorContext ctx)
{
var enemy = ctx.Ecs.Spawn();
ctx.Ecs.DespawnOnExit(enemy, Screen.Playing);
}The despawn is Bevy's own, so it reaches the entity's children as well, and it happens at the
transition rather than inside [OnExit], which means it covers every way out of the value.
A mode that only means anything inside another one is a sub-state. A pause outside a run is not "off", it is nothing, and saying so is what keeps a pause from being held when the next run starts:
public enum Screen { Menu, Playing }
[SubStateOf(typeof(Screen), Screen.Playing)]
public enum Paused { No, Yes }
app.AddState(Screen.Menu);
app.AddSubState(Paused.No); // after its parent, which it is computed fromWhile Screen is anything but Playing the state does not exist, so App.TryState<Paused>(out var held) answers false rather than a value, and a method scoped to [InState(Paused.Yes)] does
not run. Entering Playing brings it into existence at Paused.No every time, which is why a
pause left on when a run ended is off again when the next one begins. [OnEnter], [OnExit] and
DespawnOnExit work on it exactly as they do on a plain state, because the relationship is
written on the enum rather than at the call.
One sub-state per state, and a sub-state cannot itself be a parent. Both are refused rather than half-worked, because the bridge pairs each sub-state with one state: Bevy names the parent as an associated type, so the pairs exist when the native library is built.
A transition is queued rather than immediate. It lands at Bevy's next transition point, so every system in the frame agrees on which state it is in rather than some seeing the change halfway through.
A Bevy state is a Rust type and C# cannot define one, so the bridge provides eight state slots
that hold an integer, and each enum claims one the first time it is added. A slot is one
independent state machine rather than one value, because the integer it holds gives an enum as
many members as it likes, and eight is the number of unrelated machines a game can run at once, which
is past what most need. Running out reports it, and raising the count is a list in
native/bevy_csharp/src/states.rs and a rebuild, at about four seconds of build time per slot. [InState] is a run condition, so it composes
with [RunIf] and [ToggleKey] rather than replacing them, and a method carrying more than one
runs only when all of them pass.
Components say what an entity is and resources what the world has. Neither says what just happened, which is why a collision or a button press otherwise becomes a component invented to carry it. A message is sent by one system and read by any number of others, none of which need know about each other.
public readonly record struct Collided(Entity A, Entity B);
ctx.Send(new Collided(a, b));
foreach (var hit in ctx.Read<Collided>())
Console.WriteLine($"{hit.A} hit {hit.B}");A reader sees the previous frame's messages. The queue is swapped once at the top of each frame, so every reader sees the same complete set, exactly once, whatever stage it runs in and whatever order the systems happen to run in. The cost is a frame of latency, since a message is not readable in the frame it was sent, including by the sender.
Bevy's own messages instead give each reader a cursor, which lets it catch up within the frame. A
cursor needs a stable identity per reader, and a C# system has none the engine can see, so the
swap is what makes "exactly once" true here. ctx.Send is safe from a parallel behavior method,
like ctx.Cmd; reading is main-thread only.
What the window reports arrives on the same bus, so an engine message is read exactly like one another system sent:
foreach (var resized in ctx.Read<WindowResized>())
Layout(resized.Width, resized.Height);
foreach (var focus in ctx.Read<WindowFocusChanged>())
if (!focus.Focused) Pause();WindowResized, WindowFocusChanged, WindowCloseRequested, WindowScaleFactorChanged,
CursorEntered and CursorLeft. WindowCloseRequested is a request rather than a fact. The
window is still open, which is the chance to save or to ask whether the player meant it, and
App.RequestExit is what actually goes.
Files dragged onto the window arrive the same way:
foreach (var hovered in ctx.Read<FileHovered>())
ShowDropTarget(hovered.Path);
foreach (var _ in ctx.Read<FileHoverCancelled>())
HideDropTarget();
foreach (var dropped in ctx.Read<FileDropped>())
LoadLevel(dropped.Path);One message per file, so dropping three sends three. The path is absolute and outside the asset directory, so it is read with ordinary file APIs rather than through the asset server. Every hover ends in either a drop or a cancellation.
An asset that will not load says why the same way:
foreach (var failed in ctx.Read<AssetLoadFailed>())
Console.Error.WriteLine($"{failed.Path}: {failed.Reason}");A handle reports that a load failed and nothing more, so this is what tells a misspelled path apart from a file that is there and unreadable. It arrives in every profile, because an asset that will not load is exactly as wrong in a headless run and harder to notice there.
ctx.Ecs.SetParent(moon, planet);
var parent = ctx.Ecs.ParentOf(moon); // planet
var children = ctx.Ecs.ChildrenOf(planet); // [moon]
ctx.Ecs.ClearParent(moon);A child's Transform is relative to its parent, and Bevy combines them during propagation, so a
parented entity only has to describe its own motion. Parenting goes through Bevy's relationship
API rather than a raw component write, which is what keeps the reverse child list correct.
GlobalTransform is the result of that propagation: where the entity sits in world space.
ref var world = ref ctx.Ecs.GetRef<GlobalTransform>(moon);
world.Translation; // world-space position
world.Forward; // the direction it faces
world.TransformPoint(new Vec3(0f, 0f, -1f)); // a local point, in world space
world.ToTransform(); // position, rotation and scaleRead it and write Transform: propagation overwrites GlobalTransform every frame, and it is a
frame behind a Transform written during PostUpdate or later, which is when propagation has
already run. It stores an affine matrix rather than a position/rotation/scale triple, because a
chain of arbitrary transforms cannot always be expressed as one, so Scale, Rotation and
ToTransform() decompose it the way Bevy's own accessors do.
Parenting is a structural change, so queue it on ctx.Cmd when calling from inside a loop.
A system runs on Bevy's main thread with the world loaned to it. When the generator fans a per-entity loop out across worker threads, those threads can safely write through the component reference they were handed, the partitions are disjoint, but they cannot touch the world.
ctx.Ecsimmediate, main thread only. From a worker it throws with a message telling you so, rather than corrupting the world.ctx.Cmda thread-safe queue, applied at the end ofPostUpdate.ctx.Time,ctx.Inputplain snapshots, safe to read anywhere.
Queue structural changes rather than applying them mid-loop. Spawning, despawning, adding and removing all move entities between archetypes, which invalidates every reference the loop holds:
[OnUpdate]
public void Tick(BehaviorContext ctx)
{
Fuse -= ctx.Time.Delta;
if (Fuse <= 0f) ctx.Cmd.Despawn(ctx.Entity); // not ctx.Ecs.Despawn
}What a behavior can reach. Everything here is Bevy's own, bridged rather than reimplemented, so a
Transform written from C# is the transform the renderer reads and a sound played from C# is an
entity in the same world.
A struct you declare is registered with Bevy from its layout. Bevy's own components are the opposite problem, because they are Rust types C# has no handle on, so they are asked for by name. That name is the only difference, and the type carries it, so they are used exactly like any other component:
var entity = ctx.Ecs.Spawn();
ctx.Ecs.Add(entity, Transform.At(0f, 5f, 0f));
ref var transform = ref ctx.Ecs.GetRef<Transform>(entity);
transform.Translation.Y -= 9.81f * ctx.Time.Delta;
foreach (var row in ctx.Ecs.Query<Transform>())
row.Component.Translation.X += 1f;That is Bevy's real Transform, not a copy kept in sync, so propagation and rendering see the
write. A struct becomes one of these by implementing INativeComponent, which names the engine
type. ComponentType<T> then resolves that name instead of registering a fresh component, and
everything downstream already works in ids, so queries, [With] filters, change detection and
ctx.Cmd reach it with no separate API: there is one Add, and it does not care which kind of
component it was handed. NativeComponents exposes the raw ids for the handful of entry points
that take one rather than a type (HasById, CountById, RemoveById, ChangedById, and the
Chunks overload that iterates a named component).
public struct Transform : INativeComponent
{
readonly string INativeComponent.NativeName => "Transform";
// ... fields laid out exactly as Bevy's
}Only a component C# can mirror byte for byte can be read or written, and the mirrors are checked
against the engine the first time an id is resolved. They are easy to get subtly wrong in a way
nothing else catches. Transform uses Rust's default representation, so the compiler reorders its
fields to save padding: Quat is sixteen-byte aligned and moves ahead of the two vectors, giving
offsets of 0, 16 and 28 rather than the source order. Both layouts are 48 bytes, so a size check
passes either way and the mistake shows up as stretched geometry. The check compares every offset.
ChildOf and Children hold a relationship and a Vec, neither of which raw bytes can
represent, so they are name-only handles: Has<ChildOf>(), Count<Children>() and [With]
filters work, while reading or writing one is refused rather than corrupting the world. Use
SetParent, ParentOf and ChildrenOf for the hierarchy itself.
The list is curated rather than general, because each entry needs a mirror written by hand as
well as a name the bridge resolves. It holds Transform, GlobalTransform, ChildOf,
Children, Visibility, InheritedVisibility, ViewVisibility, WorldInstance, Interaction
and Atmosphere.
Whether an entity is drawn. Render builds only, since a headless bridge has no such component and says so when the id is resolved.
ctx.Ecs.Add(entity, Visibility.Hidden); // and everything below it
ctx.Ecs.GetRef<Visibility>(entity).Mode = VisibilityMode.Inherited;
ctx.Ecs.GetRef<InheritedVisibility>(entity).IsVisible; // after the hierarchy is walked
ctx.Ecs.GetRef<ViewVisibility>(entity).IsVisible; // after culling: did a camera see itVisibility is the request and the other two are Bevy's answers, computed during PostUpdate
and overwritten every frame. InheritedVisibility reports whether an ancestor hides the entity;
ViewVisibility reports whether a camera actually rendered it, which is the one to check before
doing work that only matters on screen.
Set Visibility on an entity that is already drawable. Adding it writes the component but does
not pull in the two Bevy computes from it, which arrive with the mesh.
var mesh = AssetServer.Load(AssetKind.Mesh, "models/ship.gltf");
if (mesh.IsLoaded) { }
AssetServer.Release(mesh);Loading is asynchronous, so Load returns as soon as the request is queued and the handle
reports Loading until the file has been read.
Paths resolve against Config.AssetRoot, and it is worth setting. Left unset, Bevy looks for an
assets directory beside the running executable, which for a .NET app is whichever host launched
it, so under dotnet test or dotnet exec that is the host rather than the assembly and assets
copied next to the DLL are not found. Naming the directory outright is the only way to be sure:
AssetRoot = Path.Combine(AppContext.BaseDirectory, "assets")A glTF file holds many assets, so one is named with a label after the path. LoadGltfMesh builds
that label, and what comes back is an ordinary mesh handle:
var hull = AssetServer.LoadGltfMesh("models/ship.gltf"); // mesh 0, primitive 0
Render.SetMesh(ctx.Ecs, entity, hull);
Render.SetMaterial(ctx.Ecs, entity, Render.CreateMaterial(0.6f, 0.6f, 0.62f));A glTF mesh is a named group and it is the primitive inside it that carries geometry, which is why both indices exist; a file exported as one object is mesh 0, primitive 0. Needs a render build, since the loader comes with the renderer.
A file's own arrangement of its meshes is a scene, and spawning one produces the entities the artist laid out:
var root = ctx.Ecs.SpawnScene(AssetServer.LoadGltfScene("models/ship.gltf"));The root comes back at once and fills in when the asset has loaded, so no children on the first
frame is normal rather than a failure. Wait by polling ChildrenOf, not on the WorldInstance
component, which marks the spawn as done but can appear a frame before the entities are
visible.
Compose on top of what a file describes by patching it after it spawns, which is what Bevy's own
bsn! does at compile time in Rust and what the ECS surface here does at runtime:
foreach (var child in ctx.Ecs.ChildrenOf(root))
{
ctx.Ecs.Add(child, Transform.At(3f, 0f, 0f)); // override what the artist set
ctx.Ecs.Add(child, new Selectable()); // add what the file knows nothing about
}.scn and .scn.ron worlds load as the same asset through AssetKind.Scene, so SpawnScene
takes either.
A file's own materials load too, in a windowed run:
Render.SetMaterial(ctx.Ecs, entity, AssetServer.LoadGltfMaterial("models/ship.gltf"));A glTF material loads as a GltfMaterial, which describes a material rather than being one the
renderer draws with, and the translation between them belongs to the renderer. A windowless run
has no renderer and nothing to draw, so it has no translated material either; use
CreateMaterial if a run without a window needs one at all.
Bevy's own handle is generic and reference counted, and neither property survives a trip through
a C ABI, so C# holds a key into a table on the engine side that owns the real handle. Holding one
keeps the asset loaded; Release gives up that reference. The key carries a generation as well
as a slot index, so a released handle does not start naming whatever later took its slot. It
names nothing instead, and every call that takes one refuses it rather than carrying on without
whatever it pointed at.
Mesh and Image load in any build. StandardMaterial and Shader need a render build, and
asking for one without it reports which build would support it. Scenes load too: Scene is a
trait in 0.19 and the loadable asset behind .scn, .scn.ron and a glTF file's scenes is
WorldAsset, which ctx.Ecs.SpawnScene spawns.
Meshes and materials can be built without an asset file, and attached to an entity to make it
drawable. This needs a render build; on a headless one every call here refuses and says which build
would support it, rather than silently doing nothing. Guard with App.HasRenderer to write one
behavior that runs either way, as BevyCSharp.Sample/Behaviors/Scene.cs does.
var camera = Render.SpawnCamera3d();
ctx.Ecs.Add(camera, Transform.LookingAt(new Vec3(0f, 6f, 12f), Vec3.Zero, Vec3.UnitY));
Render.SpawnLight(LightKind.Directional, 10_000f);
var mesh = Render.CreateMesh(MeshShape.Cuboid, 1f, 1f, 1f);
var material = Render.CreateMaterial(0.25f, 0.55f, 0.85f);
var entity = ctx.Ecs.Spawn();
Render.SetMesh(ctx.Ecs, entity, mesh);
Render.SetMaterial(ctx.Ecs, entity, material);Handles are references, so one mesh and one material can be shared by any number of entities. Attaching a mesh goes through Bevy's own insert rather than a byte copy, which is what pulls in the components Bevy requires alongside it, so an entity needs nothing further to be drawn.
Materials. A material takes settings, and its textures are image handles:
var crate = Render.CreateMaterial(new MaterialSettings
{
BaseColorTexture = AssetServer.Load(AssetKind.Image, "textures/crate.png"),
NormalMap = AssetServer.Load(AssetKind.Image, "textures/crate-normal.png"),
Roughness = 0.8f,
});
var glass = Render.CreateMaterial(new MaterialSettings
{
BaseColor = (0.8f, 0.9f, 1f, 0.25f),
AlphaMode = AlphaMode.Blend,
});A texture is combined with its matching factor rather than replacing it, so a base color map on the default white shows unchanged and tinting it is a matter of setting a color. The image need not have finished loading, because the material holds a handle rather than pixels. Five maps are bound this way: base color, normal, metallic-roughness, emissive and occlusion.
AlphaMode decides what happens where a material is not opaque. Mask draws a pixel or skips it,
deciding at AlphaCutoff, so the surface still writes depth and nothing has to be sorted, which is
what foliage and fences are drawn with. Blend is real transparency, drawn after everything else
and sorted back to front. Add adds to what is behind, so it never darkens it. DoubleSided draws
back faces, for anything modelled as a single sheet, and Unlit shows the base color flat.
Textures. How one is sampled is decided when it loads:
var floor = AssetServer.LoadImage("textures/tiles.png", TextureSettings.Tiling);
var bumps = AssetServer.LoadImage("textures/tiles-normal.png", TextureSettings.Data);Tiling repeats and filters linearly; Data filters linearly and reads the file as raw values
rather than as sRGB, which is what a normal, roughness or occlusion map needs. Individual settings
are there for anything else, including anisotropy, which is dropped rather than refused if the
filters are not all linear, because the graphics API treats that pair as a validation failure.
Tiling takes both halves. A mesh's UVs run from zero to one however large it is, so a repeating
texture still shows one stretched copy until the material scales them with UvScale = (12f, 12f).
PNG, JPEG, WebP, BMP and TGA decode in every build, headless included, because that is work on data
rather than on a GPU.
Cameras. A camera and a light take settings, and every value has a usable default:
var camera = Render.SpawnCamera3d(new CameraSettings
{
FieldOfView = 55f,
Clear = ClearMode.Custom,
ClearColor = (0.02f, 0.03f, 0.05f, 1f),
});
Render.SpawnLight(new LightSettings
{
Kind = LightKind.Spot,
Intensity = 40_000f,
Color = (0.4f, 0.6f, 1f),
OuterAngle = 0.5f,
});CameraProjection.Orthographic swaps perspective for a fixed vertical Height, which is what an
isometric or top-down view is built on. Order decides which camera draws over which, and
ClearMode.Keep layers one on another. A light is aimed by its Transform, since a directional or
spot light shines down its own negative Z, which is what Transform.LookingAt produces.
Viewport gives a camera part of the window instead of all of it, which is what splitscreen is made
of, and Layers decides what a camera can see at all, which is what a minimap is made of:
const uint Minimap = 1u << 1;
Render.SpawnCamera3d(new CameraSettings { Viewport = (0, 0, 640, 720) });
Render.SpawnCamera3d(new CameraSettings
{
Viewport = (640, 0, 640, 720),
Order = 1,
Clear = ClearMode.Keep, // or it would wipe out the first camera's half
Layers = Minimap,
});
Render.SetLayers(ctx.Ecs, marker, Minimap); // only the minimap draws it
Render.SetLayers(ctx.Ecs, player, 1u | Minimap); // both doA viewport is measured in physical pixels rather than logical ones, because that is what a framebuffer is divided into. A camera draws an entity only where their layers overlap.
Shadows are tuned per light and sized globally:
Render.SpawnLight(new LightSettings
{
Kind = LightKind.Directional,
ShadowDepthBias = 0.05f, // against shadow acne
ShadowNormalBias = 1.2f, // against acne on glancing surfaces
});
Render.SetShadowMapSize(directional: 4096);Bias is per light because one light's acne is another's floating shadow. Size is one number for every directional light and one for every point and spot light, because that is how Bevy keeps it, and raising it costs memory and fill rate on every shadow-casting light at once.
The picture the camera makes is one call, describing the whole pipeline rather than one change to it:
Render.SetPostProcessing(camera, new PostSettings
{
Hdr = true, // highlights brighter than white, which bloom reads
Bloom = true,
BloomIntensity = 0.3f,
Tonemapper = Tonemapper.AgX,
AntiAlias = AntiAliasPass.Fxaa,
Msaa = 1,
Sharpen = 0.4f,
});Every effect is applied on every call, so an effect the settings leave off is taken off the camera, which means turning bloom off is the same call as turning it on. Only a camera takes these, since it is the camera's render graph that reads them.
A tonemapper is the curve from what was rendered, which has no upper bound, to what a display can
show, which does. All eight of Bevy's are there, from None through Reinhard to AgX and Bevy's
own TonyMcMapface; the choice is a look rather than a correctness question, and it shows most with
Hdr on. Msaa smooths the edges of geometry while the scene is rasterised, while AntiAlias runs
a pass over the finished picture and so also catches edges that come from a texture or a shader.
Fxaa is the cheap one and Smaa the sharper one; Temporal resolves each frame from the ones
before it, so it sees an edge sampled many times over, at the cost of a trail behind anything whose
motion the renderer reports wrongly. It needs a 3D camera and Msaa = 1, and asking for it
alongside multisampling throws rather than quietly drawing nothing. Bloom scatters light out of
whatever is brighter than white, so it needs Hdr and something emissive to work on. To make one
object glow harder, raise its material's emissive color rather than the bloom.
Either side of that are two more calls. SetExposure is what the scene is metered at, in EV-100,
which is the photographer's number: around 15 for sunlight, 12 for an overcast day and 7 indoors.
SetColorGrading is the look applied after tonemapping, in the three tonal ranges a colorist works
in:
Render.SetExposure(camera, 12f);
Render.SetColorGrading(camera, new GradingSettings
{
Temperature = -0.15f, // cooler overall
Shadows = new GradingSection { Lift = 0.02f }, // lifted blacks
Highlights = new GradingSection { Saturation = 0.9f }, // calmer highlights
});MidtonesRange says which luminances count as the middle, so it decides how much of the picture
each of the three sections has to work on. The terms inside a section are the standard ASC CDL ones,
so a grade written for a film pipeline carries across unchanged. Passing null puts the camera back
to the engine's own grading.
The lens is a second call, because it is decided at a different time. The pipeline above is what a settings screen owns, and these are what a scene does for a moment.
Render.SetEffects(camera, new EffectSettings
{
DepthOfField = DepthOfFieldMode.Bokeh, // focus, and a disc around every highlight past it
FocalDistance = 8f,
Aperture = 1.4f,
ShutterAngle = 0.5f, // a film camera's 180 degree shutter
Aberration = 0.02f, // colored fringes on the edges
Distortion = 0.3f, // a wide lens bulging the picture outwards
Vignette = 0.4f, // corners going dark
AutoExposure = true, // the camera metering the frame for itself
});The whole lens in one call, so an effect the settings leave off is taken off the camera. Depth of
field needs a perspective camera, since focus has no meaning without one, and Aperture is in
f-stops, so a smaller number is a wider lens and less of the scene in focus. Motion blur reads where
each pixel moved, which costs a second pass over the scene, and that pass goes away again when the
shutter angle does. AberrationColors swaps the red, green, blue fringe for any image, read across
its width. Auto exposure builds a histogram of the frame and moves the exposure so the average lands
on middle grey, which is what an eye does walking out of a cave; MeteringMask weights where in the
frame it looks, and ExposureCompensation bends the result so a night scene can stay dark.
The sky can be scattered rather than painted:
Render.SetAtmosphere(camera, new AtmosphereSettings());
Render.SetPostProcessing(camera, new PostSettings { Hdr = true });
Render.SetSkyLighting(camera, intensity: 1f); // and let it light the sceneBevy computes the color of every direction from how far sunlight travels through the air to reach it, so the horizon reddens, the zenith stays pale, and the whole sky turns over as the sun moves. Distant geometry picks up the same haze. The sun is whichever directional light is in the scene, so pointing that light differently moves the sky, and a scene with no directional light gets a night sky.
The sky is a planet-sized entity that the camera looks out from, and SetAtmosphere keeps at most
one of them, so calling it for a second camera adds a viewer rather than a second sky. The planet is
measured in metres with its ground at the origin, which is why a scene measured in something else
sets Scale rather than moving anything. Density thickens or thins the air, HazeDistance
decides how far ahead the haze is computed, and ClearAtmosphere takes the sky off a camera again.
The camera is given a high dynamic range target either way, because a sun scattered through air is
far brighter than white.
SetSkyLighting derives an environment map from that atmosphere each frame, so a surface picks up
the color of what is around it rather than only what a lamp points at it, and the light follows the
sun without anything being animated. The size it generates is a square cubemap resolution and has to
be a power of two. ClearSkyLighting takes it off.
A painted sky is a cubemap instead:
Render.SetSkybox(camera, AssetServer.Load(AssetKind.Image, "sky.png"), brightness: 1500f);The file is a column of six square faces, which is the layout cubemap textures ship in, and it is
turned into a cube once it has decoded. brightness is in candelas per square metre like the rest
of the lighting, so the useful numbers are in the hundreds or thousands; a brightness of one is a
night sky and comes out black. A skybox is seen behind the scene and does not light it.
Drawing into an image. A camera can draw into a texture instead of into the window, which is what a portal, a security monitor, a mirror or a second viewport is:
var target = Render.CreateTarget(512, 512);
var watcher = Render.SpawnCamera3d(new CameraSettings { Order = 1 });
ctx.Ecs.Add(watcher, Transform.LookingAt(new Vec3(0f, 6f, 0f), Vec3.Zero, Vec3.UnitZ));
Render.SetCameraTarget(watcher, target);
// The same handle, read as a texture, so the screen shows what that camera sees.
Render.SetMaterial(ctx.Ecs, screen, Render.CreateMaterial(new MaterialSettings
{
BaseColorTexture = target,
}));The image is empty until something draws into it, and the handle is usable on the frame it is
returned, because nothing loads. Render.SetCameraTarget(camera, AssetHandle.None) puts the camera
back on the window, and Render.Screenshot(path, target) writes out what it drew.
A picture can also come back into memory rather than into a file, which is what asserting on what was drawn needs:
var ticket = Render.BeginCapture(target); // or BeginCapture() for what the run is drawing
// A frame or two later, because the picture has to come back off the GPU:
if (Render.TryReadCapture(ticket, out var picture))
{
var (r, g, b, a) = picture.At(16, 8); // four bytes a pixel, rows top to bottom
}TryReadCapture answers false while the picture is still on its way, hands it over once it has
arrived, and drops the engine's copy when it does. Render.ReleaseCapture(ticket) is for a caller
that stopped waiting. A capture taken in the first frames of a run is a picture of a cleared window,
because a material's pipeline is compiled the first time something asks to be drawn with it.
The window can be driven while the app runs:
Window.SetTitle("Level 2");
Window.SetMode(WindowMode.BorderlessFullscreen);
Window.SetCursor(CursorGrab.Locked, visible: false);
Window.SetPosition(100, 100);
Window.SetStyle(decorations: false, resizable: false, alwaysOnTop: true);
var (width, height) = Window.Size();WindowMode.Fullscreen takes the monitor exclusively at its current video mode, which can be worth
a frame of latency and makes alt-tabbing heavier; BorderlessFullscreen is what most desktop games
want. CursorGrab.Locked is what a first-person camera needs, since it reads how far the mouse
moved rather than where it is. Platforms differ in which grab they support, Windows confining and
macOS locking and each emulating the other, so hide the cursor while it is grabbed either way.
The monitors are readable, which is what a settings screen offers a choice from:
for (var i = 0; i < Window.MonitorCount(); i++)
{
var m = Window.Monitor(i);
var name = Window.MonitorName(i);
Console.WriteLine($"{(name.Length > 0 ? name : $"Display {i + 1}")}: {m.Width}x{m.Height} at {m.RefreshHz:F0} Hz");
}A monitor's name is read separately from the rest of it, because it is text. Platforms name a monitor nothing often enough that a settings screen wants the fallback shown above. A headless run has no window, and every call here says so rather than doing nothing.
A 2D camera measures in pixels from the middle of the window, and sprites are entities under it:
Render2d.SpawnCamera2d();
var badge = ctx.Ecs.Spawn();
Render2d.SetSprite(ctx.Ecs, badge, AssetServer.Load(AssetKind.Image, "ui/badge.png"));
ctx.Ecs.Add(badge, Transform.At(120f, -80f, 0f));A sprite is a picture in the world rather than on the screen. It carries a Transform like
anything else, so parenting, hierarchy and every other component work on it. For something pinned
to the screen regardless of the camera, use Ui instead.
SpriteSettings tints, resizes, mirrors, and picks one rectangle out of a sheet, which is how a
single image holds many frames:
Render2d.SetSprite(ctx.Ecs, badge, sheet, new SpriteSettings
{
Rect = (0f, 0f, 32f, 32f),
FlipX = facingLeft,
});An atlas layout does the same counting for you. It is a list of rectangles over a grid of equal tiles, so a frame is named by number rather than by arithmetic, and stepping an animation is adding one:
var frames = Render2d.CreateAtlas(32, 32, columns: 8, rows: 1);
Render2d.SetSprite(ctx.Ecs, walker, sheet, new SpriteSettings
{
Atlas = frames,
Frame = step % 8,
Anchor = SpriteAnchor.BottomCenter,
});The layout takes no image, because it describes a cut rather than a picture, so one layout serves
every sheet cut the same way. Anchor moves the transform off the middle of the sprite, which is
what anything standing on the ground wants, and SpriteAnchor names the nine usual points.
Mode decides how the picture meets Size: Sliced keeps the corners and stretches the middle,
so one small image draws a panel at any size, Tiled repeats it instead, and Scaled keeps the
picture's proportions and letterboxes what is left over, with Scaling saying whether it is fitted
inside the size or made to fill it and which edges are kept.
Ordering a 2D camera above a 3D one draws it over the scene without clearing, which is how a 2D overlay sits on a 3D game.
Debug drawing, for watching what a program is doing:
Gizmos.Line(from, to, (0.3f, 0.8f, 1f, 1f));
Gizmos.Arrow(position, position + velocity, (1f, 0.4f, 0.2f, 1f));
Gizmos.Sphere(position, 0.35f, (1f, 0.85f, 0.2f, 1f));
Gizmos.Axes(transform, 1.5f);Fourteen shapes in all. Line and Fade for a plain or a dying line, Arrow where a line has to
say which way along it, Sphere, Circle, Arc, Rect and Grid for a volume, a plane, an angle
or a floor, Box, Capsule, Cone, Cylinder and Torus for the shapes a collider or a radius
of effect usually is, and Axes for an orientation. Everything but a line takes a Quat, because a
shape with a flat side has to be told which way it faces.
Gizmos.Arc(joint, facing, radius: 1.2f, angle: MathF.PI / 3f, (0.9f, 0.9f, 0.2f, 1f));
Gizmos.Box(bounds.Center, Quat.Identity, bounds.Size, (0.2f, 1f, 0.4f, 1f));
Gizmos.Grid(Vec3.Zero, Quat.Identity, across: 20, down: 20, spacing: 1f, (1f, 1f, 1f, 0.15f));A gizmo lasts one frame, so anything that should stay on screen is asked for again every frame.
That is what makes them right for a value that changes and wrong for anything permanent, which
wants an entity. Axes colors itself red, green and blue for X, Y and Z, which is the quickest
way to see whether something faces where it should.
inFront decides whether the scene may hide a shape, and it is true everywhere except Grid. A
handle, an outline or a marker is drawn about the scene and has to be reachable; a grid, a path
or a wireframe is drawn in it and has to be behind what is in front of it. Gizmos.Configure
sets the line width, which render layers gizmos appear on, and whether they are drawn at all,
which is what a debug overlay bound to a key wants.
Gizmos are drawn by a plugin that comes with the window, so a windowless run refuses rather than
collecting shapes nothing will draw. Guard with App.HasRenderer.
An interface is Dear ImGui, running in C# and drawn by Bevy. Immediate mode: a call per widget
per frame, no document to load, no binding to declare, and nothing to keep in step with the world.
It is part of the library rather than part of the editor, so a game gets one by referencing
BevyCSharp and nothing else.
[Behavior]
public partial struct Interface
{
private static int _score;
[OnStartup] public static void Open(BehaviorContext ctx) => ImGuiRuntime.Start();
[OnUpdate]
public static void Tick(BehaviorContext ctx)
{
ImGuiRuntime.Begin(ctx);
if (ImGui.Begin("Sample"))
{
ImGui.Text($"Score {_score}");
if (ImGui.Button("Add one")) _score++;
}
ImGui.End();
ImGuiRuntime.End();
}
}BevyCSharp.Sample does exactly that, in Behaviors/Interface.cs. It needs a bridge with the
interface compiled in (build/build-native.sh --editor) and Config.Gui asked for.
The engine only rasterises. ImGui hands over vertices, indices and a list of draw calls, each
with a clip rectangle and a picture; bcs_imgui_frame takes them and a pass in Bevy's renderer
draws them straight onto the window, over whatever the cameras drew. Nothing on the Rust side knows
what a widget is, which is why the interface can change completely without touching it.
Pictures come from the asset server. ImGuiTextures.Load("icons/ui/camera.png") answers a name
ImGui can put in a draw call, decoded by the engine like any other asset, and drawn with
ImGui.Image tinted to whatever it means.
Input is fed, not polled. ImGuiRuntime.Begin turns the engine's per-frame input into the
events ImGui expects, and ImGuiRuntime.WantsMouse is what stops the camera flying, or a click
picking something behind a panel, while the interface has the pointer. SyntheticInput writes into
both halves, ImGui's queue and the window's own messages, so a test selects a mesh and drags a
handle the way a hand does.
Panels and text, on a render build:
var panel = Ui.SpawnNode(new UiSettings
{
Absolute = true,
Left = Length.Px(16f),
Top = Length.Px(16f),
Padding = Length.Px(10f),
Color = (0f, 0f, 0f, 0.45f),
});
var label = Ui.SpawnText("Score: 0", new UiSettings { Color = (1f, 1f, 1f, 1f) }, 18f);
ctx.Ecs.SetParent(label, panel);
Ui.SetText(label, $"Score: {score}");A length carries its unit, because a bare number cannot say whether it means pixels, a share of
the parent, or "work it out": Length.Px, Length.Percent, Length.Auto. Absolute pins a node
to its parent's edges rather than laying it out beside its siblings, which is what a HUD wants.
A node stacks its children along one axis, which is what turns a pile of them into a screen:
var menu = Ui.SpawnNode(new UiSettings
{
Direction = UiDirection.Column,
Align = UiAlign.Center,
RowGap = Length.Px(12f),
Padding = Length.Px(16f),
Border = Length.Px(2f),
BorderColor = (0.4f, 0.7f, 1f, 1f),
});Direction is that axis, Justify spreads the children along it and Align places them across
it: a column centred with Align is a menu, a row spread with UiJustify.SpaceBetween is a
toolbar. RowGap and ColumnGap space the children apart from the parent's side, which is
steadier than a margin on each of them.
Padding, Margin and Border are four lengths each, and a single Length assigned to one of
them means the same distance on every side:
Padding = Length.Px(16f), // all four
Border = Sides.Vertical(Length.Px(2f)), // a rule above and below
Margin = new Sides(Length.Px(8f), Length.Zero, Length.Auto, Length.Zero),A border draws only where BorderColor is not transparent. Length.Auto in a margin is not zero:
it swallows whatever room the parent has left over, which is how the third line above pushes a
node to the right without the parent arranging it.
Nodes are entities, so nesting is SetParent and removal is Despawn, and a node can carry your
own components like anything else. SetText rewrites in place rather than respawning, because a
score changes every frame and the entity behind it should not.
Text is set in the font Bevy compiles in, so words reach the screen with no asset loaded at all. A game that wants its own loads it like anything else:
var font = AssetServer.Load(AssetKind.Font, "fonts/inter.ttf");
Ui.SpawnText("Score: 0", new UiSettings { Color = (1f, 1f, 1f, 1f) },
new UiTextSettings { Font = font, FontSize = 18f });TrueType and OpenType. A handle that names nothing is refused rather than falling back quietly,
because a game that ships a font and silently does not use it looks exactly like a font that
failed to load. Asking for a font by family name, the way a web page asks for sans-serif, is not
offered: Bevy resolves those through system_font_discovery, which links against fontconfig on
Linux, and the bridge builds with nothing but a C compiler.
A label fits on one line; a paragraph has to be told how to break:
Ui.SpawnText(paragraph, new UiSettings { Color = (1f, 1f, 1f, 1f) }, new UiTextSettings
{
FontSize = 14f,
Justify = TextJustify.Center,
Wrap = TextWrap.WordBoundary,
});The width it breaks against comes from the layout, so the text or something above it needs a
Width or a MaxWidth; a node free to grow sideways never wraps however Wrap is set.
TextWrap.NoWrap is the opposite choice, for a line that should run past the edge and be clipped
rather than folded. Justify aligns the lines against each other inside the text's own box, which
is a different question from where that box sits in its parent.
UiSettings.Color is the text's color rather than a background for a run of text, and it is
transparent by default like any other node, so a SpawnText that passes plain new UiSettings()
lays out correctly and draws nothing.
LineHeight sets the spacing between lines, as a multiple of the font size unless
LineHeightInPixels says otherwise. Smooth turned off keeps a pixel font sharp, since smoothing
a font drawn to land on whole pixels is what makes it look blurred. ShadowOffset and
ShadowColor put a shadow behind the glyphs, which is what keeps light text readable over a
picture that might be light too.
The children answer back. Grow takes a share of whatever room the parent has left over, Shrink
gives up a share of the overflow, Basis is the size to start from, and AlignSelf overrides the
parent's alignment for one child. MinWidth and its three companions bound the result, Wrap runs
the children onto more lines, and Display takes a node out of the layout altogether:
var filler = Ui.SpawnNode(new UiSettings { Grow = 1f, Basis = Length.Px(0f) });
var fixedWidth = Ui.SpawnNode(new UiSettings { Shrink = 0f, Width = Length.Px(64f) });
var menu = Ui.SpawnNode(new UiSettings { Display = UiDisplay.None }); // put away, not despawnedUiDisplay.None is not Visibility.Hidden: the first takes the node's space back and moves its
siblings up, the second stops it drawing and leaves the hole. A screen that is toggled wants the
first, a health bar that blinks the second.
OverflowX and OverflowY say what happens to contents past an edge: drawn anyway, clipped, or
clipped and scrollable. Bevy has no scrolling of its own, so a list is moved by reading the wheel
like any other input and calling Ui.SetScroll(list, 0f, offset). ClipBox says where the
clipping falls, which for a list with a border is the difference between rows disappearing at the
border and disappearing inside it, and ClipMargin pushes that line out by a few pixels for a
shadow or a focus ring.
AspectRatio decides the side the layout was not told about, so a tile stays square while only its
width is being decided, and AlignContent spreads the lines a wrapped node produced the way
Justify spreads the children within one line.
Camera names which camera draws the screen, and carries to the node's children. Left alone, Bevy
picks whichever camera draws to the window, which is what a game wants; a run drawing into an image
has none, so a screen that should appear in an offscreen capture names the camera itself.
A node can hold a picture as well as a color:
var icon = Ui.SpawnNode(new UiSettings { Width = Length.Px(32f), Height = Length.Px(32f) });
Ui.SetImage(icon, AssetServer.Load(AssetKind.Image, "ui/icon.png"));UiImageSettings tints it, mirrors it, cuts one icon out of a sheet with Rect or by frame number
with Atlas and Frame, and chooses how it meets the node's size. The layout Atlas takes is the
one Render2d.CreateAtlas makes, so a sheet of icons serves the world and the interface without
being cut a second way. UiImageMode.Sliced is the one worth knowing. The image is cut into
nine, the corners keep their size and the middle stretches, so one small picture draws a panel at
any size. Auto keeps the picture's own size, which is what a node with no width or height of
its own then takes.
A node can be asked to report the pointer, which is what makes it a button:
var button = Ui.SpawnNode(new UiSettings
{
Interactive = true,
Width = Length.Px(140f),
Height = Length.Px(40f),
Color = (0.15f, 0.35f, 0.6f, 1f),
});
var state = Ui.InteractionOf(button); // None, Hovered or PressedPressed lasts from the frame the pointer goes down until it is released, so a click is the edge
into it, which is the previous answer kept in a behavior field and compared. An interactive node captures
the pointer, so nothing behind it is hovered through it, and a plain node carries nothing to
update, which is why it is not the default. Asking a plain node is refused rather than answered
None, since a button that quietly never fires is the harder mistake to find.
var clip = AssetServer.Load(AssetKind.Audio, "sounds/hit.ogg");
Audio.Play(clip, AudioSettings.Effect); // plays once, then despawns itself
var music = Audio.Play(theme, AudioSettings.Music);
Audio.SetVolume(music, 0.2f);
Audio.Stop(music);Ogg Vorbis, WAV, FLAC and MP3. A sound that is playing is an entity, so it can be despawned,
parented, tagged with your own components and found by a query, and Play hands that entity
back. PlaybackMode.Despawn is what a one-shot effect wants, because nothing has to remember to clean
it up.
SetVolume, Pause and Resume reach the sink Bevy attaches once playback has started, so they
report NotPresent if called in the same frame the sound was started in. So do PositionOf and
Seek, which read and move the point a clip has reached:
var at = Audio.PositionOf(music); // seconds into the clip
Audio.Seek(music, at - 5f); // back five seconds
Audio.SetGlobalVolume(0.4f); // the master slider, over everything at onceA looping sound refuses to be sought, because looping keeps the decoded samples so the clip can start again and what holds them has no way to move within them. Music that has to resume where it left off is played once and restarted rather than looped.
Start and Play cut a window out of a clip, which is how one file holds several effects:
Audio.Play(footsteps, new AudioSettings { Start = 1.2f, Play = 0.35f, Mode = PlaybackMode.Despawn });One decode covers the sheet, rather than one file and one decode per effect. Play left at zero
runs to the end of the clip.
A sound can be placed in the world instead of played into both ears equally. That takes two things: the sound saying so, and an entity to hear from.
Audio.SetListener(Render.SpawnCamera3d()); // usually the camera
var engine = Audio.Play(hum, new AudioSettings
{
Mode = PlaybackMode.Loop,
Spatial = true,
SpatialScale = 0.01f, // a world measured in pixels rather than metres
});
ctx.Ecs.Add(engine, Transform.At(4f, 0f, -2f));A spatial sound is given a Transform to be moved by, and is heard quieter with distance and
further to one side as it crosses the listener. SpatialScale is what makes that work in a world
whose units are not metres.
Sound is in the render profile rather than the minimal one, and not because it draws. It is the one part of the engine that needs a system library at build time. See .github/BUILDING.md.
Keys tell you what the hardware did; Input.Text tells you what the user meant. It is this
frame's typed characters, after the keyboard layout and any dead keys have been applied, which is
what a name field needs:
name += ctx.Input.Text;
if (ctx.Input.KeyPressed(Key.Backspace) && name.Length > 0)
name = name[..^1];Control characters are left out, because Backspace and Enter arrive as text on some platforms and
a field that inserted them would be wrong on all of them. Read those as keys, as above. Text is
empty on most frames and never null.
Touches arrive the same way, as this frame's list:
foreach (var touch in ctx.Input.Touches)
if (touch.Phase == TouchPhase.Started) Aim(touch.X, touch.Y);A touch that ends is reported once, on the frame it ends, and is gone after that. Gamepads are
deliberately excluded: bevy_gilrs needs libudev headers at build time on Linux, which the
bridge avoids so it builds with nothing but a C compiler.
Three ways to run the same behaviors, and one way to change them without stopping.
The sample has a switch at the top of Program.cs:
const bool RunInWindow = false;
const GraphicsBackend Backend = GraphicsBackend.Vulkan;or from the command line, which wins over the constants:
build/build-native.sh --render # once: build a bridge with the renderer
dotnet run --project BevyCSharp.Sample # a rotating cube
dotnet run --project BevyCSharp.Sample -- --backend vulkan
dotnet run --project BevyCSharp.Sample -- --headless --frames 120
dotnet run --project BevyCSharp.Sample -- --offscreen --frames 120The sample opens a window by default and draws a lit cube turning in place. Escape closes it.
There are three ways to run the same behaviors, and Config chooses between them. A window is the
usual one. Headless installs no renderer, which is what a test or a dedicated server wants.
Offscreen installs the renderer and draws into an image instead of onto a screen, which is the
only one of the three that produces a picture on a machine with no display server:
var config = Config.OffscreenFor(1280, 720, frames: 120);Width and Height size the image the way they would size the window, Render.Screenshot writes
it to a PNG, and HeadlessFps and HeadlessFrames pace and bound the run, because a run with no
window has no window to close. The editor takes --offscreen as well, so the interface itself can
be captured where there is no screen to draw it on.
The camera is steered the way an editor's scene view is, so the scene can be looked at from anywhere while trying something out:
| held | does |
|---|---|
| right button | look around, with W, A, S, D to fly, Q and E for down and up, Shift for faster and Control for slower, and the wheel to set the speed |
| middle button | slide the view sideways and up |
| wheel | move along the view direction |
| Alt and left button | swing around a point in front of the camera |
| F | frame the origin from wherever the camera is looking |
BevyCSharp.Sample/Behaviors/FlyCamera.cs is the whole of it, and it is an ordinary behavior:
it keeps its own yaw, pitch and speed as component fields, reads ctx.Input, and writes Bevy's
Transform.
Both modes run the identical behavior scripts. Nothing branches on whether a renderer exists;
the engine decides that, from Config.
Config.Backend pins the graphics API. Automatic already prefers Vulkan on Linux and Windows,
so naming it is about making the choice explicit and failing loudly rather than falling back
silently. App.DescribeAdapter() reports what you actually got, which is how you check:
[Renderer] adapter: Vulkan | NVIDIA GeForce RTX 4070 Laptop GPU | DiscreteGpu | NVIDIA
[Renderer] 245.7 fps frame 840 spinners 3
Ask for a backend the machine has no driver for and startup fails with a message saying so, rather than quietly picking something else.
Cameras, lights, meshes and materials are reachable from C# through Render, which is what
draws the scene in the screenshot above. See Drawing for the calls.
The editor profile watches the asset directory, so a running app picks up what changed on disk.
Config.WatchAssets turns it on.
Assets reload: a texture, a mesh or a font changed on disk is picked up by the running app.
Behavior scripts reload too. A script is an ordinary [Behavior] struct in a .cs file that is
compiled while the app runs, with the same source generator the compiled projects use, so what it
gets is the same runner and the same scheduling:
[Behavior]
public partial struct Spin
{
public float Speed;
public float Angle;
[OnUpdate]
public void Tick(BehaviorContext ctx)
{
Angle += Speed * ctx.Time.Delta;
ctx.Ecs.GetRef<Transform>(ctx.Entity).Rotation = Quat.FromRotationY(Angle);
}
}Two pieces in this library make that possible, and neither involves a compiler.
App.EnableDynamicSystems puts a dispatcher in each stage before the loop starts, because a
schedule cannot be added to once Bevy owns it, and that is where a system compiled later goes.
App.RemoveSystemsBySource retires the generation being replaced, which is why each one
registers under a tag of its own. A generation's [OnStartup] runs when it arrives rather than
when the app began, so a reloaded script spawns what it needs and clears out what the last one
left.
A script that does not compile changes nothing. The errors are reported and the running
generation stays. The compiler itself lives in BevyCSharp.Editor, because a game should not
carry one in order to run.
Both are ordinary BevyCSharp apps rather than privileged ones, which is what lets anything learned building them apply to building a game.
BevyCSharp.Editor runs the same way the sample does and is built on the same library, with no
privileged path into the engine, because the editor is a BevyCSharp app whose behaviors happen to
draw an editor.
build/build-native.sh --editor
dotnet run --project BevyCSharp.EditorThe scene fills the window and the panels float over it. The panel on the right holds the world
beside the details of whatever is selected, the tools float in the scene's corners, and the tabs
along the bottom open the console, the asset browser, the settings and the style. Docking the panel
gives the camera the rectangle that is left rather than drawing it behind. The arrangement is a
handful of numbers that EditorShell owns and every part reads, saved with the settings, so the
editor opens the way it was left. The look is one theme file, assets/theme.txt, which the Style
tab writes. .github/EDITOR.md has the design language in full.
Two things it is built on belong to the library rather than to the editor, and are what any tool would use.
Showing a component needs no reflection. The generator emits a ComponentSchema for every
[Behavior] struct, holding each field's name, its kind, and a pair of closures that read and write
it, and ComponentSchemas maps a live component id to it. So an entity's components can be listed
and edited without naming a single type:
foreach (var id in ctx.Ecs.ComponentsOf(entity))
{
if (ComponentSchemas.For(id) is not { } schema) continue;
foreach (var field in schema.Fields)
Console.WriteLine($"{schema.Name}.{field.Name} = {field.Read(ctx.Ecs, entity)}");
}A field whose type is a struct with fields of its own is taken apart, so Front.Held.At is a row
called At, in a fold called Held, in one called Front. Writing one reads the component,
changes that part and writes it back, so a part written does not wipe its neighbours. Bevy's own
components are the curated list, because each needs a byte-compatible mirror written by hand.
A field says how it wants to be drawn, in attributes the generator reads at compile time, so nothing reflects at runtime:
[Range(0, 1, Readout = SliderReadout.Number)] public float Weight; // a bar, and the number
[Separator] // under a line
[Info("Changing this rebuilds the shape.", Kind = NoteKind.Warning)] // said in the panel
[OnValueChanged(nameof(Rebuild))] public float Radius; // and something to call
[ShowIf(nameof(Mode), Mode.Running)] public float WhileRunning; // only while it is
[Inline] public Vec3 Corner; // three boxes, one row
[Wide] public int Seed; // no name column at all
[Button("Save", Line = ButtonLine.Start, Weight = 2)] public void Save() { }
[Button("Load", Line = ButtonLine.End)] public void Load() { }A schema also carries how to add the component, how to remove it, and any method the struct has
that takes nothing, so a panel offers those as buttons without naming a type. What the editor
changes can be taken back: EditorHistory records an operation only when it can be reversed
exactly, which is why despawning is not recorded, an entity's mesh and material having no mirror on
this side.
Everything written to the output and error streams is teed into ConsoleLog, a ring of levelled
lines that collapses repeats, so a console can show it without anything that writes a line knowing
a console exists. What can be typed into one is a static method with [Command] on it:
[Command("select", "Selects the first entity with a name: select <name>")]
internal static string Select(string name) { … }A generator finds them at compile time and a module initialiser registers them, so nothing reflects at runtime and a command survives trimming. Parameters are read from the words after the name and may be strings, numbers or flags; a single string parameter takes the whole of what was typed after it. Returning a string writes that line back, and anything a person can get wrong is answered with a sentence rather than an exception.
ConsoleCommands.Run(line) is the whole of the runtime surface, so a game gets a console by
drawing one. The editor's is the tab along the bottom, which the key under Escape raises and puts
away, and everything it knows about the log and the commands it asks the library for.
The same catalog is reachable from a terminal. Config.Serve, or --serve, or BCS_SERVE in the
environment, opens a socket on the loopback interface and writes a session file, and bcs finds it
and asks it things:
./bcs open --editor # start one, detached, and wait until it answers
./bcs open --editor --offscreen # the same, on a machine with no display
./bcs list # every command that app offers, with its parameters
./bcs command entity.set Cube Transform.Translation 0,2.5,0
./bcs command input.click 1450 700
./bcs command frames.wait 5
./bcs shot /tmp/after.png # captures the window, and waits for the fileEach of those is answered inside the next frame of the app that is already running, which is the point, because a fresh process per question costs a second of startup, a new world, and a guess about which frame to look at. What arrives over the socket is queued and run by a system at the top of the frame, because everything ECS-touching is ambient on the world Bevy lends the running system. The socket thread never touches an entity.
Every verb writes one envelope to the standard output stream under --json, whether it worked or
not, with a stable token in errors[0].code and an exit code that separates it failed from
nothing was there to ask:
{ "success": true, "command": "command", "data": { "result": "…", "frame": 962 },
"errors": [], "warnings": [] }bcs also wraps the cold paths, in the order this repository needs them: bcs build builds the
bridge and then the managed side, bcs test runs the suite and exits 8 when tests fail and 6 when
the run never reached a verdict, and bcs doctor answers why nothing is starting. bcs help lists
the rest.
Nothing about this is privileged. The plugin ships in the library and is off unless asked for, so a
game built on BevyCSharp is drivable exactly the way the editor is. The editor additionally
registers eval, which compiles a fragment of C# and runs it against the live world through the
same script host that reloads behavior scripts.
your game (C#)
│ [Behavior] structs
▼
BevyCSharp.Generator Roslyn generator: emits one runner per behavior plus a
│ module initializer that announces them
▼
BevyCSharp (managed) App, World, EcsWorld, EcsCommands, BehaviorContext,
│ Time, Input, the behavior runners
▼ C ABI
bevy_csharp (Rust cdylib) dynamic component registration, exclusive systems,
│ chunked table access, frame-state mirroring
▼
Bevy 0.19 ECS, scheduler, time, input, windowing, renderer
A few decisions worth knowing about:
Components are registered at runtime. Bevy normally learns component layouts from Rust types
at compile time. C# types are not available to it, so each blittable struct is registered with its
size and alignment through Bevy's dynamic ComponentDescriptor support. From that point it is an
ordinary Bevy component. It lives in tables, participates in archetypes, and Bevy's own change
detection sees it.
Iteration is zero-copy. A query hands C# raw pointers into Bevy's table storage. The per-entity loop writes straight into the component column, no marshalling, no staging buffer.
C# systems are exclusive systems. While managed code can spawn and despawn at any moment, that is the only sound option, so Bevy serialises C# systems against each other. The parallelism that matters is still there, inside the per-entity loop, which is where the entity counts are.
Registration happens at assembly load. The generator emits a module initializer per assembly, so every registration has announced itself before the app is built. A reflection scan covers assemblies that are loaded but untouched.
Early. The behavior system, the ECS bridge and the schedule work and are covered by tests that run against a real Bevy app. Known gaps:
- A locally built package contains only the platform you built it on. Use the CI workflow, or
run
build-native.shon each target platform, to produce a package covering all of them. - A render build draws: mesh primitives, textured physically based materials, cameras, lights,
sprites, gizmos, UI nodes and text are reachable from a behavior script, verified on Vulkan.
glTF files and
.scnscenes load and spawn, audio plays, and a camera tonemaps, blooms, multisamples, antialiases, scatters a sky over what it draws, pulls focus and finds its own exposure. What is thin is the layer above that. Animation has no bridge, sprites step through no frames of their own, and the GPU-compressed texture formats are not decoded. .github/TODO.md lists what each gap needs. BehaviorsPlugin.ScriptsDirectoryis reserved for hot-reloading behavior scripts and does nothing yet. The editor reloads scripts throughApp.EnableDynamicSystemsinstead, because the compiler lives there.- The editor's world file keeps what this side can describe: an entity's name and every component with a schema. A component the engine owns and C# has no mirror for, a mesh handle or a material, is not written, so the file is a set of edits over a scene rather than the scene.
- Component filters must be table-stored components, which is everything C# registers. A filter naming a Bevy-side sparse-set component is rejected rather than silently wrong.
- An image to draw into is created empty at a size. Loading one, or creating a cubemap, has no bridge, so a skybox and the light probes that would light a room to match it are out of reach.
Using the package needs none of this, since it ships a prebuilt bridge for every supported platform. This is for working on the bridge itself.
build/build-native.sh # the headless bridge
dotnet build # what copies it beside each project's binaries
dotnet test BevyCSharp.Tests/BevyCSharp.Tests.csprojThe two halves are built separately, and a rebuilt bridge is invisible until a managed build copies
it, which is why the second line is not optional. build/build-native.sh --render adds the renderer
and --editor adds the interface on top of it.
.github/BUILDING.md has the rest: the three native profiles and what each costs, the platforms and their prerequisites, how the package is assembled for six runtime identifiers, and how a release is published.
Prose in this repository follows .github/STYLE.md: no em dashes, no spaced hyphens as punctuation, no colon joining two clauses where a full stop or a "because" belongs, no padded section banners, and comments that explain why rather than restate the code.
Mozilla Public License 2.0. The full text is in LICENSE, and it ships inside the package.
MPL-2.0 is file-level copyleft: changes to files that are part of this project have to stay under it and be made available in source form, while anything you build around it, including a game that references the package, is yours under whatever terms you like. Bevy itself is MIT and Apache-2.0, which this can incorporate freely.



