Building a 2D Game with WinUI and Win2D

For a while I've been working building a little collection of classic arcade games for Windows to better understand Win2D and game development, and to see what is possible with WinUI.. You can see the games in action at games.xaml.dev or grab it right from the store. In this post, I'll break down how the rendering comes together and build a small example: a keyboard-controlled ship flying over a layered starfield.

You really only need very little graphics infrastructure to get started. A few lines, circles, translucent colors, and a managing some drawing orders can take you a long way.

Bringing XAML and immediate-mode rendering together

Space Arcade is a WinUI application. For this tutorial, we'll combine two systems to build a similar little interactive app:

System What it does
XAML Layout, menus, buttons, score displays, and other normal application UI
Win2D Draws the playfield: backgrounds, ships, terrain, projectiles, and effects


Win2D
is a hardware-accelerated 2D drawing API built on Direct2D. Instead of creating a XAML element for every projectile or star, I draw them into a CanvasControl.

That is an immediate-mode approach: each draw callback describes the scene again. The application keeps the world state, but it doesn't keep a separate XAML visual for every object. Immediate-mode rendering is very typical in game development where you have very frequent changes to what is on the screen.

XAML, by contrast, is retained-mode UI. A score label remains a label; a button remains a button and you don't have to manage redrawing or updating it after declaring it. It keeps its layout, keyboard navigation, and accessibility behavior without my renderer having to recreate those features.

The beauty is that these systems can coexist in the same Grid:

<Grid>
    <canvas:CanvasControl Draw="OnDraw" />
    <StackPanel Margin="24" IsHitTestVisible="False">
        <TextBlock Text="SCORE" />
        <TextBlock Text="000000" />
    </StackPanel>
</Grid>

Here, canvas maps to using:Microsoft.Graphics.Canvas.UI.Xaml. Children declared after appear above earlier children, so the text sits above the playfield. Since this is a decorative overlay, I use IsHitTestVisible="False" so it doesn't intercept pointer input in case I want to use the mouse to interact with the game area. However real buttons should remain interactive, so don't disable hit tests on those or the user can't click them.

The important lesson here is: not everything animated on screen needs to become part of the game renderer. Use XAML where it makes sense.

The other separation: input, update, and draw

Inside the game, there are three responsibilities:

Keyboard events ----> held-key state
                            |
Rendering notification ---> update scene using elapsed time
                            |
                            +---> request canvas redraw
                                       |
Canvas Draw callback ----------------> draw current scene

XAML controls remain above the canvas.

The XAML page owns input and lifecycle. A scene or world owns positions, movement, and effects. The renderer reads that state and converts it into drawing commands, and we update that state every frame and based on user-inputs. Some of the drawing code lives directly in its page; more involved rendering can live in a dedicated renderer.

For the tutorial, we'll use three small pieces:

File Responsibility
GamePage.xaml and .xaml.cs Canvas hosting, frame timing, keyboard input, pause, and cleanup
Scene.cs Ship movement, star positions, and particle lifetimes - ie. the game state.
SceneRenderer.cs Drawing, transforms, colors, and layer order


The complete sample project (ZIP) accompanies this post. The snippets below introduce the pieces in order; they are excerpts or intermediate versions, not extra code to paste on top of the final sample.

1. Start with one shape

In a WinUI project with the Microsoft.Graphics.Win2D package installed, a CanvasControl gives us a CanvasDrawingSession in its Draw event that is called at some point after a draw has been requested.

The first renderer can be this small:

private void OnDraw(CanvasControl sender, CanvasDrawEventArgs e)
{
    CanvasDrawingSession ds = e.DrawingSession;
    ds.Clear(Color.FromArgb(255, 5, 11, 25));
    ds.FillCircle(200, 150, 12, Color.FromArgb(255, 140, 239, 242));
}

Those types come from Microsoft.Graphics.Canvas, Microsoft.Graphics.Canvas.UI.Xaml, and Windows.UI.

Clear the canvas, then draw a circle. Pretty basic:

A single cyan circle at control coordinates 200, 150 on a dark canvas.

2. Choose a coordinate system before adding movement

Drawing directly in the control's coordinates is fine for the first dot. For a game, I find it easier to choose a fixed logical playfield.

Our sample uses 960 by 540 logical units, independent of the window size:

ds.Clear(Color.FromArgb(255, 5, 11, 25));

float scale = MathF.Min(ActualWidth / Scene.Width, ActualHeight / Scene.Height);
Vector2 offset = new(
    (width - Scene.Width * scale) / 2,
    (height - Scene.Height * scale) / 2); // Center

ds.Transform =
    Matrix3x2.CreateScale(scale) *
    Matrix3x2.CreateTranslation(offset);

ds.FillCircle(Scene.Width / 2, Scene.Height / 2, 12,
    Color.FromArgb(255, 140, 239, 242));
width and height are the canvas's ActualWidth and ActualHeight, converted to float.

The transform maps the fixed 960 × 540 game coordinates onto the actual canvas. The circle's position (480, 270) is the center of the logical world, not necessarily the center of the control.

For example, on a 1200 x 800 canvas:

  • Scale is 1.25, making the playfield 1200 x 675.
  • Translation adds 62.5 units of padding at the top.
  • The circle's center becomes (600, 400) -the canvas center- and its radius scales from 12 to 15.

Without the transform, it would remain at (480, 270) with radius 12, regardless of window size.

For just one centered circle, we could calculate its screen coordinates directly. The transform becomes useful when drawing an entire scene: every shape, position, and stroke scales together, while movement and gameplay stay in the same logical coordinate system.

Using the smaller scale preserves the aspect ratio. The remaining space becomes letterboxing. A ship at (480, 270) stays in the center, and its movement speed stays in logical units per second. If you don't want letterboxing, that's fine too, but requires more testing to ensure what you want on the screen doesn't get cropped.

Note that the control dimensions are device-independent pixels, not physical monitor pixels. Win2D handles the control's DPI so we don't have to; we aren't multiplying the scale by the display's DPI again.

Clear the full canvas before applying the world transform. The final sample also clips drawing to the logical playfield using CreateLayer, so particles and glow cannot spill into the letterbox margins. Restore the previous transform afterward.

If you later support aiming with the mouse, invert this same transform to convert pointer positions back into world coordinates. Don't maintain a second, slightly different scaling calculation for input.

The cyan circle placed at the center of the 960 by 540 logical playfield.

3. Make the dot move

The ship's position belongs to the scene stored as a 2D vector. For example:

public Vector2 Player { get; private set; } = new(Width / 2, Height / 2);

Key-down and key-up events maintain a set of held keys. On each update, the page converts that set into a direction:

Vector2 direction = new(
    (Held(VirtualKey.Right, VirtualKey.D) ? 1 : 0) -
    (Held(VirtualKey.Left, VirtualKey.A) ? 1 : 0),
    (Held(VirtualKey.Down, VirtualKey.S) ? 1 : 0) -
    (Held(VirtualKey.Up, VirtualKey.W) ? 1 : 0));

The scene then applies movement and multiplies it by the amount of time passed since the last frame (dt) to make the speed constant:

if (direction.LengthSquared() > 1)
    direction = Vector2.Normalize(direction);

Velocity = direction * PlayerSpeed;
Player = Vector2.Clamp(
    Player + Velocity * dt,
    new Vector2(PlayerRadius),
    new Vector2(Width - PlayerRadius, Height - PlayerRadius));

Here, PlayerSpeed is 240 logical units per second. Normalizing the diagonal direction stops diagonal movement from being faster than horizontal movement. Opposite keys cancel out.

For timing, the sample uses CompositionTarget.Rendering as a UI-thread frame notification. Although its event argument is declared as object, we can cast it to WinUI's Microsoft.UI.Xaml.Media.RenderingEventArgs and use its RenderingTime timestamp to calculate the time passed since the previous frame:

TimeSpan now = ((RenderingEventArgs)e).RenderingTime;
if (_lastFrame is not TimeSpan previous)
{
    _lastFrame = now;
    return;
}
float dt = (float)Math.Clamp((now - previous).TotalSeconds, 0, 0.05);
_lastFrame = now;

if (_paused) return;
_scene.Update(dt, direction);
_canvas.Invalidate();

RenderingTime is a frame timestamp, not the duration of the current frame. Subtract the previous timestamp to get elapsed time. _lastFrame is a nullable TimeSpan? field, initially null; the first notification establishes a baseline without advancing the scene. Reset it to null when the page loads or pause changes so resuming doesn't include time spent paused.

The page calls Invalidate() to request drawing; that isn't a synchronous call to Draw. Requests can be coalesced and called from multiple events within a single frame and still only trigger a single draw.

The 50 ms cap is a simple defense against a giant movement jump after a stall. It deliberately discards excess elapsed time. This is a variable-step tutorial, not a deterministic physics engine (For a collision-heavy simulation, consider a fixed-step accumulator with bounded catch-up work).

Win2D also has CanvasAnimatedControl, with its own animation loop and game-loop-thread model. This example uses CanvasControl to keep input, simulation, drawing, and XAML updates on the same UI thread. That's a simplicity tradeoff, not a claim that it's inherently better for games: a busy UI thread also delays the game. CanvasAnimatedControl can isolate the game loop from that work, but requires safely transferring input and UI state between threads.

Step 3: just the dot moving, cropped around its circular path.

The clips in steps 3-5 use scripted directions through the same Scene.Update method, with Win2D-rendered frames encoded at 30 frames per second. Each shows only the drawing introduced so far, cropped to the movement area without magnification.

4. Replace the dot with a tiny ship

Instead of a circle, let's make a more recognizable character to look like a simple spaceship. We'll draw a triangle in local coordinates:

Vector2 nose = new(16, 0);
Vector2 upper = new(-11, -10);
Vector2 lower = new(-11, 10);

ds.DrawLine(nose, upper, Cyan, 1.5f);
ds.DrawLine(upper, lower, Cyan, 1.5f);
ds.DrawLine(lower, nose, Cyan, 1.5f);

These points describe a ship centered near the origin, facing right. To draw it at the player's position, compose its rotation and translation with the existing world-to-canvas transform:

Matrix3x2 previous = ds.Transform;
ds.Transform =
    Matrix3x2.CreateRotation(scene.Heading) *
    Matrix3x2.CreateTranslation(scene.Player) *
    previous;

In System.Numerics, that order applies local rotation first, then placement in the world, then the world-to-canvas transform. Reversing it can rotate the ship's position around the origin instead of rotating the ship itself.

While moving, the scene updates Heading using MathF.Atan2(Velocity.Y, Velocity.X). When movement stops, it keeps the last heading.

Restore the previous transform in a finally block after drawing the ship. Transforms are shared drawing-session state: forgetting to restore one can make the next planet or particle rotate with the player.

Step 4: the same movement now drives a small, direction-facing ship. No starfield or glow yet.

5. Build a starfield that feels like several layers

Create collecgion of stars once, each with a position, depth, and twinkle phase. The sample uses 150 stars split between three depths:

float depth = (i % 3) switch
{
    0 => 0.25f,
    1 => 0.6f,
    _ => 1
};

Randomize the initial positions and phases, but don't regenerate them in Draw. Otherwise, stars teleport between frames and the background becomes visual noise.

During updates, move them at different speeds:

Vector2 drift = new Vector2(-18, 6) - Velocity * 0.15f;
star.Position += drift * star.Depth * dt;

The constant drift keeps the background alive while the ship is stationary. The velocity term nudges it opposite the ship's movement. Larger depth values move farther, creating a simple parallax cue simulating different distances to the stars.

We'll also wrap stars when they leave the playfield, so the enter on the other side:

private static float Wrap(float value, float extent) =>
    (value % extent + extent) % extent;

C#'s remainder can be negative, so the extra normalization matters for stars moving left or up.

In the renderer, we can also vary brightness smoothly to give a twinkling effect:

float twinkle = 0.7f + 0.3f * (float)Math.Sin(
    scene.Time * (1 + star.Depth) + star.Phase);
byte alpha = (byte)(twinkle * (60 + 150 * star.Depth));

ds.FillCircle(
    star.Position,
    0.5f + star.Depth,
    Color.FromArgb(alpha, 185, 220, 245));

Depth now controls speed, size, and brightness. Each is a small signal; together they make a flat collection of dots feel more spatial.

Twinkle is a function of scene time. Drawing the same state twice produces the same appearance instead of advancing an animation twice.

Step 5: stars drift at different speeds and change brightness smoothly while the ship moves.

6. Give drawing order a name

There doesn't have to be a layer object or a separate render target for every visual layer. But it can be useful to create a method that renders each object to more logically understand the draw order.

DrawHaze(ds);
DrawStars(ds, scene);
DrawPlanet(ds);
DrawParticles(ds, scene);
DrawPlayer(ds, scene);
DrawForeground(ds);

Our haze is a handful of large, faint circles. The planet is an opaque disk with a thin illuminated outline and an elliptical ring. Its opaque body covers stars drawn behind it. The exhaust is behind the ship (see step 8), and faint scanlines are drawn last.

The XAML heading and instructions sit above all of these Win2D passes. We don't redraw that text in the canvas.

Step 6: drawing order adds haze, an opaque planet, and a foreground treatment, with XAML text above the canvas. The particle pass is still empty; we'll fill it in step 8.

7. Adding a simple neon-like glow effect

Much of Space Arcade's neon appearance comes from drawing the same primitive several times with slightly different tints:

private static void GlowLine(
    CanvasDrawingSession ds, Vector2 a, Vector2 b, Color color)
{
    ds.DrawLine(a, b, Tint(color, 0.06f), 11);
    ds.DrawLine(a, b, Tint(color, 0.2f), 5);
    ds.DrawLine(a, b, color, 1.5f);
}

Tint returns the same RGB color with an alpha derived from the supplied opacity.

The broad, faint stroke suggests scattered light. The narrower stroke builds a halo. The crisp center preserves the shape. Replacing the ship's three ordinary lines with GlowLine immediately changes its character to have a slight glow.

This is an approximation of glow, not a blur, bloom shader, or simulation of emitted light. The sample uses normal alpha compositing, not actual additive blending.

For small line-based scenes, the approximation is simple and effective. It also has a cost: three strokes instead of one, with overlapping translucent pixels. More glow isn't automatically better.

If an effect really needs a blurred silhouette, Win2D supports offscreen render targets and effects such as GaussianBlurEffect. That adds resource management, intermediate surfaces, and fill cost, and wasn't something I really needed.

Step 7, shown close-up: broad translucent strokes add a halo without replacing the crisp ship outline. This crop is magnified 2x to make the layered strokes easier to see, but the effect is still quite subtle.

8. Add a particle layer

Particles are another small simulation that we can use for exhaust that we can define with a record:

internal record struct Particle(
    Vector2 Position, Vector2 Velocity, float Life);

During updates, reduce their remaining life and advance their position. Remove expired particles while iterating backward through the list.

While the ship is moving, the sample emits exhaust behind its local forward direction at 45 particles per second. An elapsed-time accumulator determines how many to create, rather than emitting one particle per frame. This keeps the intended emission rate independent of the display's refresh rate.

Drawing is simpler than updating:

float fade = Math.Clamp(particle.Life / Particle.Lifetime, 0, 1);
ds.FillCircle(particle.Position, 5 * fade, Tint(Amber, 0.1f * fade));
ds.FillCircle(particle.Position, 1.8f * fade, Tint(Amber, fade));

Again, one faint halo and one brighter center. Shrinking and fading together makes the exhaust dissipate without any texture animation.

The tutorial emits from the latest ship position. For extremely fast motion or longer frame gaps, distributing emissions along the traveled segment produces a smoother trail.

Step 8: time-based emission adds a fading exhaust trail behind the ship, completing the sample.

The less glamorous part: focus and lifetime

A renderer that looks good for ten seconds isn't enough. It has to behave when you click, resize, switch apps, and leave the page.

The complete sample handles the essentials:

  • On Loaded, create the canvas, subscribe to drawing and frame notifications, reset the frame-time baseline, and focus the page.
  • On Unloaded, unsubscribe from frame and window events, detach drawing, and call RemoveFromVisualTree() on the canvas. A later load creates a new canvas.
  • On focus loss, clear held keys so a missed key-up event doesn't leave the ship moving.
  • On window deactivation, pause and clear input. Returning to the window does not resume automatically; The user can press P to unpause and resume the game.

CompositionTarget.Rendering is a static event. Leaving a page subscribed can keep it alive and updating after it is no longer visible. Treat subscription and cleanup as a pair. This event in general you should be careful with and always unsubscribe once you don't need it, as it causes the WinUI rendering loop to constantly run with no pauses.

Download the complete example

Download the sample source (ZIP), extract it, and open RenderingSample.csproj. The archive includes the complete packaged WinUI project, package references, manifest, and tutorial source; there's no need to scaffold a project or copy files manually.

You'll need Windows, the .NET 10 SDK, and Developer Mode. The included README has the run command for WinApp CLI 0.7 or later; you can also use Visual Studio and just launch it from there. Try moving diagonally, releasing the keys, pausing, resizing the window, and switching to another app.

How the same approach grows into different games

Once the frame timing, coordinate system, and drawing passes are in place, the visual vocabulary can change without replacing the whole application.

A scrolling terrain game adds a camera offset and draws terrain segments before ships and explosions. A circular arena builds shapes from angles and radii. A perspective-looking playfield can project logical lane and depth coordinates into 2D points before sending them to Win2D.

That last distinction is useful: a scene can look three-dimensional without being rendered by a full 3D engine. In Space Arcade, projected geometry can still be drawn as ordinary 2D lines. Logical positions remain the source of truth for gameplay.

The first useful renderer can be remarkably modest: one canvas, one scene, elapsed-time updates, and a handful of well-ordered drawing methods. The rest is merely choosing what to put in each layer.

Full demo

I would love for you to try out my game from the store now (it has a free fully playable trial!), and please leave a review in the store if you like it.

Further reading

Add comment