---
title: "Tweens and CSS transitions"
url: https://recursiveai.org/onegame/cookbook/tweens-and-transitions/
description: "Animate a C# game’s UI with tweens and CSS style transitions in onegame: slide a label in with an easing curve, blend a color on a class change, step view…"
version: "1.5.1"
---

# Tweens and CSS transitions

Animate a C# game’s UI with tweens and CSS style transitions in onegame: slide a label in with an easing curve, blend a color on a class change, step view time from a scenario, and check that the simulation never saw it.

New in 1.4.0. A tween moves a number, a vector or a color from one value to another over some seconds and hands you each value on the way. Tweens run on the view clock, not in the simulation, so they can’t change synced state and a replay or a state hash is the same with or without them. The `transition` style property does the same for a style change, with no code.

## The game

A score that slides in when it changes, and a lamp that fades between two colors. Create the game with `onegame new glow`, delete `art/` and everything in `scenarios/`, and replace `Game.cs`:

```csharp
using OneGame.Core;
using OneGame.Host;
using OneGame.Tweens;
using OneGame.UI;

namespace Glow;

/// <summary>A score that only the commands change. Everything that moves on screen is view state.</summary>
[Entity]
public partial class Score : Entity
{
    [Synced] public partial int Points { get; private set; }

    [Command]
    public void Add(int by) => Points += by;
}

public sealed class GlowGame : GameDefinition
{
    public override string Name => "glow";

    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Glow.EntityTypes.Register(registry);

    public override void Setup(World world) => world.Add(new Score());

    // A transition: when `lit` is added or removed, the background blends over 300 ms instead of jumping.
    public override void Style(UiTree ui) => ui.AddStyleSheet(new StyleSheet()
        .Rule(".lamp", s => s.Size(160, 40).Background(Color.Rgb(0x203040)).Transition("background-color 300ms ease-out"))
        .Rule(".lamp.lit", s => s.Background(Color.Rgb(0xF2C14E))));

    public override void DefineScreens(ScreenSet screens) => screens.Add("main", game =>
    {
        var score = Ui.Label(() => $"{ScoreOf(game).Points} points").Named("score").Padding(Length.Px(12)).Background(Color.Rgb(0x35506B));
        var lamp = Ui.Label("").Named("lamp").Class("lamp");
        return Ui.Column(
            Ui.Button("Score", () =>
            {
                ScoreOf(game).Send.Add(10);
                // Slide the score in from the left and land with a small overshoot. Tweens run on view time, never in the sim.
                game.Tweens.Number(-200, 0, 0.4f, x => score.Style(s => s.Translate(x, 0)))
                    .Ease(Easing.OutBack)
                    .Named("score-in")
                    .OnComplete(() => score.Class("landed"));
            }).Named("add"),
            Ui.Button("Toggle lamp", () => lamp.SetClass("lit", !lamp.HasClass("lit"))).Named("toggle"),
            score, lamp)
            .Padding(Length.Px(16)).Gap(Length.Px(12)).TextColor(Color.White);
    });

    private static Score ScoreOf(GameInstance game) => (Score)game.World.Entities[0];
}
```

- **`game.Tweens.Number(from, to, seconds, setter)`** starts a tween and returns a handle. Chain `.Ease(...)`, `.Delay(...)`, `.Repeat(...)`, `.OnComplete(...)` and `.Named(...)` on it. `Vector2`, `Vector3`, `Vector4` and `Color` work the same way, and `Sequence`, `Parallel`, `Delay` and `Call` build longer animations.
- **`Easing`** has the CSS timing functions (`ease`, `ease-in-out`, `cubic-bezier(...)`, `steps(...)`) and the named curves (`OutCubic`, `OutBack`, `InOutSine` and the rest). `Easing.Parse("ease-out")` reads the CSS text.
- **`Transition("background-color 300ms ease-out")`** follows CSS Transitions: when a listed property changes, from a class switch, `:hover` or a binding, it moves to the new value over the time given instead of jumping.
- The button sends a command for the score and starts a tween for the look. The command changes the world; the tween only changes how the label is drawn.

## Test it with scenarios

`tweens:` moves view time without running a tick, and the `tweens:` expectation counts what is running:

**`scenarios/score-and-lamp.yaml`**

```yaml
name: score-and-lamp
description: A tween slides the score in and a class change starts a style transition; both finish, and neither touches the sim.
steps:
  - click: add
  - step: 1
  - tweens: 0.2                      # 0.2 s of view time; no ticks run
  - expect: { tweens: { active: 1 } }
  - tweens: settle                   # let everything finite finish
  - expect: { tweens: { active: 0 } }
  - click: toggle
  - tweens: 0.1
  - expect: { tweens: { active: 1 } }
  - tweens: settle
expect:
  - tweens: { active: 0 }
  - ui: { selector: .landed, count: 1 }
  - ui: { selector: .lit, count: 1 }
  - state: { entity: Score, property: Points, equals: 10 }
  - tick: 1
```

```shell
onegame test
```

```text
PASS score-and-lamp (47 ms, tick 1, hash 04ddfdc2ce735cc6)
1 passed, 0 failed
```

The run ends at tick 1 although the animations took 0.7 seconds of view time. A scenario that only clicks Score and steps once ends on the same hash, `04ddfdc2ce735cc6`: the tweens never reached the world.

## Look at a tween halfway

The `tweens` tool lists what is running and moves view time, so a capture can be taken at an exact point of an animation. With the MCP client from [Drive a running game over MCP](https://recursiveai.org/onegame/cookbook/live-control-mcp/), with the 200-character cut taken out:

```text
ui_click {"id": "add"} -> {"focused":"Button#add","hovered":"Label","tick":0}
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"04ddfdc2ce735cc6"}
tweens {"advance": 0.1} -> {"time":0.1,"paused":false,"timeScale":1,"active":1,"tweens":[{"id":1,"kind":"value","name":"score-in","elapsed":0.1,"duration":0.4,"progress":0.25,"paused":false,"children":0}]}
capture_frame {"path": "shots/mid.png", "size": "360p"} -> {"path":"/tmp/onegame/glow/artifacts/shots/mid.png","width":640,"height":360,"scale":1,"sha256":"c4dc93aad5640f74a2e27774c3264b6b66c987d3325f4f3968935b7da99e5523","renderer":"NVIDIA GeForce RTX 4070","tick":1,"hash":"04ddfdc2ce735cc6"}
tweens {"settle": true} -> {"time":0.401,"paused":false,"timeScale":1,"active":0,"tweens":[]}
capture_frame {"path": "shots/end.png", "size": "360p"} -> {"path":"/tmp/onegame/glow/artifacts/shots/end.png","width":640,"height":360,"scale":1,"sha256":"cd3cba59041243a1641d59cd570620dd000a554720389e65b2da288fe8f21a15","renderer":"NVIDIA GeForce RTX 4070","tick":1,"hash":"04ddfdc2ce735cc6"}
hash {} -> {"frame":1,"tick":1,"hash":"04ddfdc2ce735cc6"}
quit {} -> {"frame":1,"tick":1,"hash":"04ddfdc2ce735cc6"}
```

In `mid.png` the score label is a quarter of the way through, still partly off the left edge. In `end.png` it sits in line with the lamp. The tick and the hash didn’t move between the two. Add `capture: { reference: ... }` after a `tweens: 0.1` step to make that halfway frame a [screenshot test](https://recursiveai.org/onegame/cookbook/screenshot-tests/).

## Good to know

- **Settle at the end of a scenario.** At 1.5.1 a tween still running when a scenario ends carries over into the next scenario of the same `onegame test` run. A scenario that clicked Score and stepped once, run before `score-and-lamp`, made its first `active: 1` check fail with “running tweens is 2, expected 1”. A `tweens: settle` step at the end fixed it.
- In a running game, the host advances `game.Tweens` once per frame by the view time that passed. A stepped run or a capture moves it one tick (1/60 s) per tick, so captures repeat.
- Starting and running tweens allocates nothing once the pool is warm. A handle works until its tween ends; `Cancel()` stops it without calling `OnComplete`, and `Complete()` jumps to the end.
- Style transitions are tweens in `ui.Tweens`, so `game.Tweens` lists them too. Layout properties such as `width` and `padding` reflow every frame while they move.
- The first style an element gets never animates, and `transition: none` or a zero duration turns it off.

Every call is in [Tweens and transitions](https://recursiveai.org/onegame/docs/tweens/).
