Skip to content

RFC: Fission 2D Game Engine API and Lowering Pipeline #97

Description

@zcourts
  • Status: Draft proposal
  • Scope: Fission 2D game authoring API, expert Scene2D API, runtime pipeline, typed identity model, closed intermediate representation, and required optimization passes
  • Primary goal: Let non-game developers write small 2D games in ordinary Rust domain language while still lowering into an optimizable engine representation suitable for high-performance rendering and deterministic testing.

1. Summary

This RFC proposes a 2D game engine layer for Fission with two public authoring tiers and one shared closed implementation target.

Everyday Game API
  Plain Rust structs, generated typed field handles, domain vocabulary.
  No string object IDs, no ECS terminology, no engine jargon required.

Expert Scene2D API
  Game-engine vocabulary for developers who want direct control:
  sprites, sprite batches, cameras, colliders, layers, instances.
  Still strongly typed; no string object IDs.

Closed Game IR / Scene2D IR
  Shared lowering target used by both APIs.
  Enables validation, deterministic replay, batching, culling,
  spatial query optimization, and renderer-independent output.

The everyday API is designed for a developer who thinks in terms like Bird, PipeGap, FlightPath, pull_bird, and score, not Entity, Component, System, Sprite, Collider, or RenderPass.

The expert API is designed for experienced game developers and engine contributors who want explicit access to the lower-level vocabulary.

Both APIs must lower to the same closed IR so that optimization passes can be applied regardless of which authoring tier was used.

2. Goals

  1. Provide a Rust-native 2D game API that is approachable to non-game developers.
  2. Avoid authored string identifiers for game objects, object groups, areas, text, parts, assets, queries, and scene nodes.
  3. Use Rust types, fields, derives, and generated typed handles as the public identity mechanism.
  4. Provide an expert Scene2D API for developers who prefer standard game-engine vocabulary.
  5. Lower both APIs into a shared closed IR.
  6. Support deterministic fixed-step simulation.
  7. Support deterministic input mapping, replay, and headless tests.
  8. Support visual rendering of images, shapes, text, repeated objects, and object parts.
  9. Support basic spatial queries using everyday terminology: touches, outside, and related helpers.
  10. Provide enough structure for batching, culling, stable identity, and diagnostics.
  11. Allow everyday and expert APIs to be mixed in the same game.
  12. Integrate with Fission as a widget-hosted game canvas and as a standalone runtime.

3. Non-goals

The initial 2D engine specified here does not include:

  1. A full physics engine.
  2. A public ECS model.
  3. 3D rendering.
  4. Multiplayer/network replication.
  5. Skeletal animation.
  6. Shader authoring.
  7. Tilemap-specific authoring syntax.
  8. Audio graph design.
  9. A visual editor.
  10. A full asset pipeline beyond typed image/font/sound handles.

These may be added later. They must not be required to implement the API in this RFC.

4. Design principles

4.1 Everyday code uses game-domain language

The everyday API should read like the game idea.

Preferred everyday terms:

Game
object
objects
area
text
show
show_each
looks_like
place
path
touches
outside
score
pull
fall
move
turn

Terms intentionally avoided in the everyday API:

entity
component
system
sprite
collider
rigid body
material
mesh
shader
render pass
texture atlas

Those terms are allowed in the expert API.

4.2 No authored string keys for game identity

Application developers must not be required to write this:

view.show("bird", &self.bird);
ctx.world().touches("bird", "pipes");

The canonical form is:

view.show(self.bird());
ctx.world().touches(self.bird(), self.gaps());

The self.bird() and self.gaps() methods are generated by #[derive(GameState)]. They carry both the Rust reference and the stable identity needed by the lowerer.

Strings may exist in the lowered IR for diagnostics, serialization, and replay. They must be generated by the framework, not manually authored as game object IDs.

4.3 Objects and fields have distinct identities

The engine must distinguish:

field identity
  The stable identity of a field in the game state, such as FlappyBird::bird.

object identity
  The stable identity of an item, such as PipeGap { id: GapId(4) }.

presentation identity
  The stable identity of a visual part or view-specific representation.

For singleton fields such as bird: Bird, field identity is usually sufficient.

For repeated collections such as gaps: Vec<PipeGap>, each item must have a stable key.

For compound visuals such as a pipe gap rendered as a top pipe and bottom pipe, each visual part must have a typed part key.

4.4 Game logic and rendering are separate but connected

Spatial gameplay queries use Touchable2D and Area2D traits on domain objects. Rendering uses GameView or Scene2D declarations.

This means collision/touch behavior is not dependent on whether a visual was shown in the current frame.

4.5 Fixed-step simulation is mandatory

The game runtime must step simulation at a fixed interval. Rendering may happen at a different rate, but simulation must not read wall-clock time directly.

4.6 High-level declarations must preserve optimization intent

The everyday API must expose enough structure for the lowerer to optimize. For example:

view.show_each(self.gaps(), |gap, item| {
    item.part(PipePart::Top)
        .looks_like(assets::Pipe)
        .top_left_at(gap.top_left());
});

This tells the lowerer:

- this is a repeated object group
- each item has a stable key
- each item has stable parts
- all top pipe visuals share the same image asset
- the group can be culled and batched

The user does not need to say “sprite batch”. The optimizer may still create one.

5. Crates and modules

The implementation must introduce these crates or equivalent modules:

fission-play
  Everyday 2D game API.

fission-scene2d
  Expert 2D scene API.

fission-game-ir
  Closed game and scene IR definitions.

fission-game-runtime
  Fixed-step runtime, input mapping, replay, tests, and optimization pipeline.

The top-level fission crate should re-export the public APIs as:

use fission::play::*;
use fission::scene2d::*;

The everyday API must not require users to import fission_game_ir directly.

6. Core public types

6.1 Scalar and geometry types

The initial implementation must provide these types:

#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct Px(pub f32);

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Place {
    pub x: Px,
    pub y: Px,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Size {
    pub width: Px,
    pub height: Px,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bounds2D {
    pub min: Place,
    pub max: Place,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Degrees(pub f32);

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PxPerSecond(pub f32);

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Color {
    pub r: f32,
    pub g: f32,
    pub b: f32,
    pub a: f32,
}

Required constructors:

impl Px {
    pub const ZERO: Px = Px(0.0);
    pub fn new(value: f32) -> Px;
}

impl Place {
    pub fn new(x: Px, y: Px) -> Place;
}

impl Size {
    pub fn new(width: Px, height: Px) -> Size;
}

impl Bounds2D {
    pub fn from_top_left(top_left: Place, size: Size) -> Bounds2D;
    pub fn from_center(center: Place, size: Size) -> Bounds2D;
    pub fn contains_bounds(&self, other: Bounds2D) -> bool;
    pub fn overlaps(&self, other: Bounds2D) -> bool;
}

impl Degrees {
    pub fn new(value: f32) -> Degrees;
}

impl PxPerSecond {
    pub fn new(value: f32) -> PxPerSecond;
}

impl Color {
    pub const WHITE: Color;
    pub const BLACK: Color;
    pub fn rgb(r: u8, g: u8, b: u8) -> Color;
    pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color;
    pub fn with_alpha(self, a: f32) -> Color;
}

Arithmetic for Px must be implemented for addition, subtraction, multiplication by f32, and division by f32.

6.2 Time types

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Tick(pub u64);

#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct GameTime {
    pub tick: Tick,
    pub seconds: f64,
}

#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct StepDuration {
    pub seconds: f64,
}

Simulation must use fixed StepDuration values supplied by GameConfig.

7. Stable identity

7.1 Stable symbols

The implementation must define an internal stable symbol type:

#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct StableSymbol(std::sync::Arc<str>);

StableSymbol is used internally for IR identity, diagnostics, snapshots, replay logs, and test output.

User code must not be required to construct StableSymbol for normal gameplay objects.

7.2 Stable keys

Repeated object groups require stable item keys.

pub trait StableKey: Clone + Eq + std::fmt::Debug + 'static {
    fn stable_key(&self) -> StableKeyValue;
}

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum StableKeyValue {
    U64(u64),
    I64(i64),
    Str(std::sync::Arc<str>),
    Tuple(Vec<StableKeyValue>),
}

The implementation must provide StableKey for:

u8, u16, u32, u64
i8, i16, i32, i64
String
Arc<str>

The implementation must not use Rust's Hash output to generate stable IDs. Rust hash output is not stable across processes.

A derive macro must be provided:

#[derive(StableKey)]
struct GapId(u32);

#[derive(StableKey)]
enum PipePart {
    Top,
    Bottom,
}

For enums, the generated key must use the enum type path and the variant name. For tuple/newtype structs, the generated key must include the type path and each field's StableKeyValue.

8. Everyday API: #[derive(GameState)]

8.1 Field categories

The derive macro must support these field annotations:

#[game(object)]
field: T

#[game(objects, key = field_name)]
field: Vec<T>

#[game(area)]
field: T

#[game(text)]
field: T

#[game(ignore)]
field: T

The attribute names are normative.

thing must not be used. The public term is object.

slot must not be used in user-facing API. The public term for generated identity-carrying values is field handle.

8.2 Generated field handles

Given:

#[derive(GameState)]
struct FlappyBird {
    #[game(object)]
    bird: Bird,

    #[game(objects, key = id)]
    gaps: Vec<PipeGap>,

    #[game(area)]
    play_area: PlayArea,

    #[game(text)]
    score: Score,

    game_over: bool,
}

The derive macro must generate methods equivalent to:

impl FlappyBird {
    pub fn bird(&self) -> ObjectField<'_, Self, Bird, __fission_fields::Bird>;

    pub fn gaps(&self) -> ObjectsField<'_, Self, PipeGap, GapId, __fission_fields::Gaps>;

    pub fn play_area(&self) -> AreaField<'_, Self, PlayArea, __fission_fields::PlayArea>;

    pub fn score(&self) -> TextField<'_, Self, Score, __fission_fields::Score>;
}

The generated marker types live in a private generated module. They are used only to make handles type-distinct.

The generated method name is the field name by default. The user may override it with:

#[game(object, accessor = player_bird)]
bird: Bird

which generates:

pub fn player_bird(&self) -> ObjectField<'_, Self, Bird, __fission_fields::Bird>;

This override is required if the generated method would conflict with an existing inherent method.

8.3 Field handle definitions

The implementation must expose these handle types:

pub struct ObjectField<'a, G, T, F> {
    symbol: StableSymbol,
    value: &'a T,
    _game: std::marker::PhantomData<G>,
    _field: std::marker::PhantomData<F>,
}

pub struct ObjectsField<'a, G, T, K, F> {
    symbol: StableSymbol,
    values: &'a [T],
    key_of: fn(&T) -> K,
    _game: std::marker::PhantomData<G>,
    _field: std::marker::PhantomData<F>,
}

pub struct AreaField<'a, G, T, F> {
    symbol: StableSymbol,
    value: &'a T,
    _game: std::marker::PhantomData<G>,
    _field: std::marker::PhantomData<F>,
}

pub struct TextField<'a, G, T, F> {
    symbol: StableSymbol,
    value: &'a T,
    _game: std::marker::PhantomData<G>,
    _field: std::marker::PhantomData<F>,
}

Required methods:

impl<'a, G, T, F> ObjectField<'a, G, T, F> {
    pub fn symbol(&self) -> &StableSymbol;
    pub fn value(&self) -> &'a T;
}

impl<'a, G, T, K, F> ObjectsField<'a, G, T, K, F> {
    pub fn symbol(&self) -> &StableSymbol;
    pub fn values(&self) -> &'a [T];
    pub fn key_of(&self, value: &T) -> K;
}

impl<'a, G, T, F> AreaField<'a, G, T, F> {
    pub fn symbol(&self) -> &StableSymbol;
    pub fn value(&self) -> &'a T;
}

impl<'a, G, T, F> TextField<'a, G, T, F> {
    pub fn symbol(&self) -> &StableSymbol;
    pub fn value(&self) -> &'a T;
}

The handle types may implement Clone and Copy when their fields permit it. Their public behavior must not require allocation.

8.4 Generated field registry

The derive macro must implement:

pub trait GameState: Sized + 'static {
    fn field_registry() -> &'static [GameFieldMeta];
}

#[derive(Clone, Debug)]
pub struct GameFieldMeta {
    pub symbol: StableSymbol,
    pub rust_field_name: &'static str,
    pub rust_type_name: &'static str,
    pub category: GameFieldCategory,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameFieldCategory {
    Object,
    Objects,
    Area,
    Text,
}

Ignored fields must not appear in the field registry.

9. Everyday gameplay traits

9.1 Touchable objects

Spatial touch queries use Touchable2D.

pub trait Touchable2D {
    fn touch_area(&self) -> TouchArea;
}

TouchArea must be:

#[derive(Clone, Debug, PartialEq)]
pub enum TouchArea {
    None,
    Circle { center: Place, radius: Px },
    Rect { bounds: Bounds2D },
    Union(Vec<TouchArea>),
}

Required constructors:

impl TouchArea {
    pub fn none() -> TouchArea;
    pub fn circle(center: Place, radius: Px) -> TouchArea;
    pub fn rect(bounds: Bounds2D) -> TouchArea;
    pub fn union(parts: Vec<TouchArea>) -> TouchArea;
    pub fn bounds(&self) -> Option<Bounds2D>;
}

The initial implementation must support exact narrow-phase tests for:

Circle vs Circle
Circle vs Rect
Rect vs Rect
Union vs any supported shape
None vs any shape

Edges touching count as touching.

9.2 Areas

Bounded areas use Area2D.

pub trait Area2D {
    fn bounds(&self) -> Bounds2D;
}

9.3 Text display

Text fields use DisplayText.

pub trait DisplayText {
    fn display_text(&self) -> String;
}

The implementation may provide a blanket implementation for T: std::fmt::Display.

10. The Game trait

The everyday game trait is:

pub trait Game: GameState + Sized + 'static {
    type Message: Clone + std::fmt::Debug + 'static;

    fn input(input: &mut InputMap<Self::Message>) {}

    fn react(&mut self, message: Self::Message, ctx: &mut GameCtx<Self>) {}

    fn step(&mut self, ctx: &mut StepCtx<Self>);

    fn show(&self, view: &mut GameView<Self>);
}

10.1 Input mapping

pub struct InputMap<M> {
    // implementation-private
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Key {
    Space,
    Enter,
    Escape,
    ArrowUp,
    ArrowDown,
    ArrowLeft,
    ArrowRight,
    Character(char),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PointerButton {
    Primary,
    Secondary,
    Middle,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InputTrigger {
    Tap,
    KeyDown(Key),
    KeyUp(Key),
    PointerDown(PointerButton),
    PointerUp(PointerButton),
}

pub struct InputBindingBuilder<'a, M> {
    // implementation-private
}

Required API:

impl<M: Clone + std::fmt::Debug + 'static> InputMap<M> {
    pub fn on(&mut self, trigger: InputTrigger) -> InputBindingBuilder<'_, M>;
}

impl<'a, M: Clone + std::fmt::Debug + 'static> InputBindingBuilder<'a, M> {
    pub fn send(self, message: M);
}

Convenience constants or functions should be provided:

pub const Tap: InputTrigger = InputTrigger::Tap;

Example:

fn input(input: &mut InputMap<Self::Message>) {
    input.on(Tap).send(FlappyMessage::PullBird);
    input.on(InputTrigger::KeyDown(Key::Space)).send(FlappyMessage::PullBird);
}

10.2 Context types

pub struct GameCtx<G: Game> {
    // implementation-private
}

pub struct StepCtx<G: Game> {
    // implementation-private
}

Required StepCtx API:

impl<G: Game> StepCtx<G> {
    pub fn tick(&self) -> Tick;
    pub fn time(&self) -> GameTime;
    pub fn dt(&self) -> StepDuration;
    pub fn speed(&self, speed: PxPerSecond) -> Px;
    pub fn world(&self) -> &WorldQueries<G>;
}

speed(PxPerSecond(v)) returns Px(v * self.dt().seconds as f32).

GameCtx is intentionally minimal in the initial implementation. It may later expose sound, scene changes, timers, and resource loading.

11. World queries

World queries use typed field handles and gameplay traits. They do not use string IDs.

pub struct WorldQueries<G> {
    // implementation-private
}

11.1 Query source traits

pub trait QueryShapeSource<'a> {
    type Game;
    type Key: StableKey;

    fn collect_shapes(&self, out: &mut Vec<QueryShape<Self::Key>>);
}

pub struct QueryShape<K: StableKey> {
    pub key: Option<K>,
    pub area: TouchArea,
}

pub trait QueryAreaSource<'a> {
    type Game;
    fn bounds(&self) -> Bounds2D;
}

The implementation must implement QueryShapeSource for:

ObjectField<'a, G, T, F>
where
    T: Touchable2D

and:

ObjectsField<'a, G, T, K, F>
where
    T: Touchable2D,
    K: StableKey

The implementation must implement QueryAreaSource for:

AreaField<'a, G, T, F>
where
    T: Area2D

TextField must not implement QueryShapeSource or QueryAreaSource.

Therefore this must not compile:

ctx.world().touches(self.score(), self.gaps());

11.2 Required query methods

impl<G> WorldQueries<G> {
    pub fn touches<'a, A, B>(&self, a: A, b: B) -> bool
    where
        A: QueryShapeSource<'a, Game = G>,
        B: QueryShapeSource<'a, Game = G>;

    pub fn outside<'a, O, A>(&self, object: O, area: A) -> bool
    where
        O: QueryShapeSource<'a, Game = G>,
        A: QueryAreaSource<'a, Game = G>;
}

Semantics:

touches(a, b)
  Returns true if any non-None touch area from a overlaps any non-None touch area from b.

outside(object, area)
  Returns true if any non-None touch area from object is not fully contained in area.bounds().

The implementation must use AABB broad-phase pruning before narrow-phase tests when either side contains more than eight shapes.

12. Everyday rendering API: GameView

GameView builds a frame-local GameIntentIR. It is called from Game::show.

pub struct GameView<'a, G: Game> {
    // implementation-private
}

Required methods:

impl<'a, G: Game> GameView<'a, G> {
    pub fn area<T, F>(&mut self, area: AreaField<'_, G, T, F>) -> AreaViewBuilder<'_, G>;

    pub fn show<T, F>(&mut self, object: ObjectField<'_, G, T, F>) -> ObjectViewBuilder<'_, G>;

    pub fn show_each<T, K, F>(
        &mut self,
        objects: ObjectsField<'_, G, T, K, F>,
        build: impl FnMut(&T, &mut ObjectItemView<'_, G, K>),
    ) where
        K: StableKey;

    pub fn text<T, F>(&mut self, text: TextField<'_, G, T, F>) -> TextViewBuilder<'_, G>
    where
        T: DisplayText;

    pub fn raw_scene2d(&mut self, build: impl FnOnce(&mut fission_scene2d::Scene2D<'_, G>));
}

12.1 Area builder

pub struct AreaViewBuilder<'a, G> {
    // implementation-private
}

Required methods:

impl<'a, G> AreaViewBuilder<'a, G> {
    pub fn size(self, size: Size) -> Self;
    pub fn top_left_at(self, place: Place) -> Self;
    pub fn background(self, color: Color) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
}

12.2 Object builder

pub struct ObjectViewBuilder<'a, G> {
    // implementation-private
}

Required methods:

impl<'a, G> ObjectViewBuilder<'a, G> {
    pub fn center_at(self, place: Place) -> Self;
    pub fn top_left_at(self, place: Place) -> Self;
    pub fn size(self, size: Size) -> Self;
    pub fn rotate(self, degrees: Degrees) -> Self;
    pub fn scale(self, scale: f32) -> Self;
    pub fn opacity(self, opacity: f32) -> Self;
    pub fn visible(self, visible: bool) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
    pub fn looks_like(self, look: impl Into<Look>) -> Self;
    pub fn clip_to<T, F>(self, area: AreaField<'_, G, T, F>) -> Self;

    pub fn part<P: StableKey>(&mut self, part: P) -> PartViewBuilder<'_, G>;
}

center_at sets the visual center. top_left_at sets the visual top-left. If neither is called, the default place is (0px, 0px) with center anchoring.

12.3 Object item builder

ObjectItemView is used inside show_each.

pub struct ObjectItemView<'a, G, K: StableKey> {
    // implementation-private
}

Required methods:

impl<'a, G, K: StableKey> ObjectItemView<'a, G, K> {
    pub fn center_at(&mut self, place: Place) -> ObjectItemPartBuilder<'_, G>;
    pub fn top_left_at(&mut self, place: Place) -> ObjectItemPartBuilder<'_, G>;
    pub fn looks_like(&mut self, look: impl Into<Look>) -> ObjectItemPartBuilder<'_, G>;

    pub fn part<P: StableKey>(&mut self, part: P) -> PartViewBuilder<'_, G>;
}

The direct item methods configure the root item visual. part configures a typed visual part of the item.

12.4 Part builder

pub struct PartViewBuilder<'a, G> {
    // implementation-private
}

Required methods:

impl<'a, G> PartViewBuilder<'a, G> {
    pub fn center_at(self, place: Place) -> Self;
    pub fn top_left_at(self, place: Place) -> Self;
    pub fn size(self, size: Size) -> Self;
    pub fn rotate(self, degrees: Degrees) -> Self;
    pub fn scale(self, scale: f32) -> Self;
    pub fn flip_horizontal(self) -> Self;
    pub fn flip_vertical(self) -> Self;
    pub fn opacity(self, opacity: f32) -> Self;
    pub fn visible(self, visible: bool) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
    pub fn looks_like(self, look: impl Into<Look>) -> Self;
}

12.5 Text builder

pub struct TextViewBuilder<'a, G> {
    // implementation-private
}

Required methods:

impl<'a, G> TextViewBuilder<'a, G> {
    pub fn top_left_at(self, place: Place) -> Self;
    pub fn center_at(self, place: Place) -> Self;
    pub fn content(self, text: impl Into<String>) -> Self;
    pub fn size(self, size: Px) -> Self;
    pub fn color(self, color: Color) -> Self;
    pub fn opacity(self, opacity: f32) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
}

If content is not called, the builder uses DisplayText::display_text from the TextField value.

12.6 Look and layer

#[derive(Clone, Debug, PartialEq)]
pub enum Look {
    None,
    Color(Color),
    Image(ImageAsset),
    Rect { fill: Color },
    Circle { fill: Color, radius: Px },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Layer(pub i32);

Required conversions:

impl From<Color> for Look;
impl From<ImageAsset> for Look;

Lower layers render first. Higher layers render later. Within the same layer, declarations render in source order, except where an optimization pass is explicitly allowed to batch adjacent compatible operations without changing output.

13. Typed assets

The implementation must provide a typed asset macro or equivalent derive. The required public shape is:

fission_assets::assets! {
    mod assets {
        image BirdYellow = "assets/bird-yellow.png";
        image BirdBlue   = "assets/bird-blue.png";
        image BirdRed    = "assets/bird-red.png";
        image Pipe       = "assets/pipe.png";
        image Cloud      = "assets/cloud.png";
    }
}

The macro must generate typed handles:

assets::BirdYellow
assets::BirdBlue
assets::BirdRed
assets::Pipe
assets::Cloud

All image handles must have type ImageAsset or a zero-sized type convertible into ImageAsset.

The path strings in the asset macro are asset paths, not runtime object identifiers. They are allowed.

14. Complete everyday example: Flappy Bird

This example is normative for API shape.

use fission::play::*;

fission_assets::assets! {
    mod assets {
        image BirdYellow = "assets/bird-yellow.png";
        image BirdBlue   = "assets/bird-blue.png";
        image BirdRed    = "assets/bird-red.png";
        image Pipe       = "assets/pipe.png";
    }
}

#[derive(Clone, Copy, Debug, StableKey)]
struct GapId(u32);

#[derive(Clone, Copy, Debug, StableKey)]
enum PipePart {
    Top,
    Bottom,
}

#[derive(Clone, Copy, Debug)]
enum BirdKind {
    Yellow,
    Blue,
    Red,
}

#[derive(Clone, Copy, Debug)]
struct Score(u32);

impl DisplayText for Score {
    fn display_text(&self) -> String {
        self.0.to_string()
    }
}

#[derive(Clone, Copy, Debug)]
struct FlightPath {
    velocity_y: PxPerSecond,
}

impl FlightPath {
    fn new() -> Self {
        Self {
            velocity_y: PxPerSecond::new(0.0),
        }
    }

    fn pull_up(&mut self, strength: Strength) {
        self.velocity_y = PxPerSecond::new(-strength.as_px_per_second());
    }

    fn next_place(&mut self, place: Place, dt: StepDuration) -> Place {
        let gravity = PxPerSecond::new(900.0);
        self.velocity_y.0 += gravity.0 * dt.seconds as f32;
        Place::new(place.x, place.y + Px(self.velocity_y.0 * dt.seconds as f32))
    }

    fn tilt_for_display(&self) -> Degrees {
        Degrees::new((self.velocity_y.0 / 12.0).clamp(-25.0, 70.0))
    }
}

#[derive(Clone, Copy, Debug)]
struct Strength(f32);

impl Strength {
    fn medium() -> Self {
        Strength(280.0)
    }

    fn as_px_per_second(self) -> f32 {
        self.0
    }
}

#[derive(Clone, Copy, Debug)]
struct Bird {
    kind: BirdKind,
    place: Place,
    flight: FlightPath,
    alive: bool,
}

impl Bird {
    fn pull_bird(&mut self) {
        self.flight.pull_up(Strength::medium());
    }

    fn fall(&mut self, dt: StepDuration) {
        self.place = self.flight.next_place(self.place, dt);
    }

    fn tilt(&self) -> Degrees {
        self.flight.tilt_for_display()
    }
}

impl Touchable2D for Bird {
    fn touch_area(&self) -> TouchArea {
        TouchArea::circle(self.place, Px::new(14.0))
    }
}

#[derive(Clone, Copy, Debug)]
struct PipeGap {
    id: GapId,
    x: Px,
    opening_y: Px,
    opening_height: Px,
    scored: bool,
}

impl PipeGap {
    const PIPE_WIDTH: Px = Px(56.0);

    fn top_bounds(&self) -> Bounds2D {
        Bounds2D::from_top_left(
            Place::new(self.x, Px::new(0.0)),
            Size::new(Self::PIPE_WIDTH, self.opening_y),
        )
    }

    fn bottom_bounds(&self, play_area: PlayArea) -> Bounds2D {
        let y = self.opening_y + self.opening_height;
        Bounds2D::from_top_left(
            Place::new(self.x, y),
            Size::new(Self::PIPE_WIDTH, play_area.size.height - y),
        )
    }

    fn top_left(&self) -> Place {
        Place::new(self.x, Px::new(0.0))
    }

    fn bottom_top_left(&self) -> Place {
        Place::new(self.x, self.opening_y + self.opening_height)
    }
}

impl Touchable2D for PipeGap {
    fn touch_area(&self) -> TouchArea {
        // This example assumes a 700px high play area for compactness.
        // A real game can store the play-area height in PipeGap or compute
        // its touch area through game-specific helper methods.
        let bottom_y = self.opening_y + self.opening_height;
        TouchArea::union(vec![
            TouchArea::rect(Bounds2D::from_top_left(
                Place::new(self.x, Px::new(0.0)),
                Size::new(PipeGap::PIPE_WIDTH, self.opening_y),
            )),
            TouchArea::rect(Bounds2D::from_top_left(
                Place::new(self.x, bottom_y),
                Size::new(PipeGap::PIPE_WIDTH, Px::new(700.0) - bottom_y),
            )),
        ])
    }
}

#[derive(Clone, Copy, Debug)]
struct PlayArea {
    size: Size,
}

impl Area2D for PlayArea {
    fn bounds(&self) -> Bounds2D {
        Bounds2D::from_top_left(Place::new(Px::ZERO, Px::ZERO), self.size)
    }
}

#[derive(Clone, Copy, Debug)]
enum FlappyMessage {
    PullBird,
    Restart,
}

#[derive(GameState)]
struct FlappyBird {
    #[game(object)]
    bird: Bird,

    #[game(objects, key = id)]
    gaps: Vec<PipeGap>,

    #[game(area)]
    play_area: PlayArea,

    #[game(text)]
    score: Score,

    game_over: bool,
}

impl FlappyBird {
    fn new() -> Self {
        Self {
            bird: Bird {
                kind: BirdKind::Yellow,
                place: Place::new(Px::new(80.0), Px::new(300.0)),
                flight: FlightPath::new(),
                alive: true,
            },
            gaps: vec![
                PipeGap {
                    id: GapId(0),
                    x: Px::new(320.0),
                    opening_y: Px::new(220.0),
                    opening_height: Px::new(150.0),
                    scored: false,
                },
            ],
            play_area: PlayArea {
                size: Size::new(Px::new(400.0), Px::new(700.0)),
            },
            score: Score(0),
            game_over: false,
        }
    }
}

impl Game for FlappyBird {
    type Message = FlappyMessage;

    fn input(input: &mut InputMap<Self::Message>) {
        input.on(Tap).send(FlappyMessage::PullBird);
        input.on(InputTrigger::KeyDown(Key::Space)).send(FlappyMessage::PullBird);
    }

    fn react(&mut self, message: Self::Message, _ctx: &mut GameCtx<Self>) {
        match message {
            FlappyMessage::PullBird => {
                if !self.game_over {
                    self.bird.pull_bird();
                }
            }
            FlappyMessage::Restart => {
                *self = FlappyBird::new();
            }
        }
    }

    fn step(&mut self, ctx: &mut StepCtx<Self>) {
        if self.game_over {
            return;
        }

        self.bird.fall(ctx.dt());

        let pipe_speed = ctx.speed(PxPerSecond::new(120.0));
        for gap in &mut self.gaps {
            gap.x = gap.x - pipe_speed;

            if !gap.scored && gap.x + PipeGap::PIPE_WIDTH < self.bird.place.x {
                gap.scored = true;
                self.score.0 += 1;
            }
        }

        if ctx.world().touches(self.bird(), self.gaps())
            || ctx.world().outside(self.bird(), self.play_area())
        {
            self.game_over = true;
        }
    }

    fn show(&self, view: &mut GameView<Self>) {
        view.area(self.play_area())
            .size(self.play_area.size)
            .background(Color::rgb(143, 211, 255));

        let bird_image = match self.bird.kind {
            BirdKind::Yellow => assets::BirdYellow,
            BirdKind::Blue => assets::BirdBlue,
            BirdKind::Red => assets::BirdRed,
        };

        view.show(self.bird())
            .center_at(self.bird.place)
            .size(Size::new(Px::new(34.0), Px::new(24.0)))
            .looks_like(bird_image)
            .rotate(self.bird.tilt());

        view.show_each(self.gaps(), |gap, item| {
            item.part(PipePart::Top)
                .top_left_at(gap.top_left())
                .size(Size::new(PipeGap::PIPE_WIDTH, gap.opening_y))
                .looks_like(assets::Pipe)
                .flip_vertical();

            item.part(PipePart::Bottom)
                .top_left_at(gap.bottom_top_left())
                .size(Size::new(
                    PipeGap::PIPE_WIDTH,
                    self.play_area.size.height - gap.bottom_top_left().y,
                ))
                .looks_like(assets::Pipe);
        });

        view.text(self.score())
            .top_left_at(Place::new(Px::new(24.0), Px::new(24.0)))
            .size(Px::new(32.0))
            .color(Color::WHITE);
    }
}

15. Expert API: Scene2D

The expert API may use standard game-engine vocabulary. It must still use typed field handles and typed asset handles.

pub struct Scene2D<'a, G> {
    // implementation-private
}

Required methods:

impl<'a, G> Scene2D<'a, G> {
    pub fn camera<T, F>(&mut self, area: AreaField<'_, G, T, F>) -> Camera2DBuilder<'_, G>;

    pub fn sprite<T, F>(&mut self, object: ObjectField<'_, G, T, F>) -> Sprite2DBuilder<'_, G>;

    pub fn sprites<T, K, F>(
        &mut self,
        objects: ObjectsField<'_, G, T, K, F>,
        build: impl FnMut(&T, &mut SpriteItem2DBuilder<'_, G, K>),
    ) where
        K: StableKey;

    pub fn text<T, F>(&mut self, text: TextField<'_, G, T, F>) -> Text2DBuilder<'_, G>
    where
        T: DisplayText;
}

Required builders:

pub struct Camera2DBuilder<'a, G> { /* private */ }
pub struct Sprite2DBuilder<'a, G> { /* private */ }
pub struct SpriteItem2DBuilder<'a, G, K: StableKey> { /* private */ }
pub struct Text2DBuilder<'a, G> { /* private */ }

Required Camera2DBuilder methods:

impl<'a, G> Camera2DBuilder<'a, G> {
    pub fn fixed_size(self, size: Size) -> Self;
    pub fn center_at(self, place: Place) -> Self;
    pub fn zoom(self, zoom: f32) -> Self;
    pub fn clear_color(self, color: Color) -> Self;
}

Required Sprite2DBuilder methods:

impl<'a, G> Sprite2DBuilder<'a, G> {
    pub fn image(self, image: impl Into<ImageAsset>) -> Self;
    pub fn position(self, place: Place) -> Self;
    pub fn size(self, size: Size) -> Self;
    pub fn rotation(self, degrees: Degrees) -> Self;
    pub fn scale(self, scale: f32) -> Self;
    pub fn opacity(self, opacity: f32) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
}

Required SpriteItem2DBuilder methods:

impl<'a, G, K: StableKey> SpriteItem2DBuilder<'a, G, K> {
    pub fn sprite(&mut self) -> Sprite2DBuilder<'_, G>;
    pub fn part<P: StableKey>(&mut self, part: P) -> Sprite2DBuilder<'_, G>;
}

Required Text2DBuilder methods:

impl<'a, G> Text2DBuilder<'a, G> {
    pub fn content(self, text: impl Into<String>) -> Self;
    pub fn position(self, place: Place) -> Self;
    pub fn size(self, size: Px) -> Self;
    pub fn color(self, color: Color) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
}

15.1 Mixing everyday and expert API

Everyday games may use raw_scene2d for a local expert section:

fn show(&self, view: &mut GameView<Self>) {
    view.show(self.bird())
        .center_at(self.bird.place)
        .looks_like(assets::BirdYellow);

    view.raw_scene2d(|scene| {
        scene.camera(self.play_area())
            .fixed_size(self.play_area.size)
            .clear_color(Color::rgb(143, 211, 255));

        scene.sprites(self.gaps(), |gap, item| {
            item.part(PipePart::Top)
                .image(assets::Pipe)
                .position(gap.top_left())
                .size(Size::new(PipeGap::PIPE_WIDTH, gap.opening_y));
        });
    });
}

raw_scene2d is not an opaque draw callback. It emits structured Scene2D declarations that still lower to closed IR and remain optimizable.

16. Runtime host API

The game engine must be runnable both standalone and inside Fission UI.

16.1 Standalone runtime

pub struct GameRuntime<G: Game> {
    // implementation-private
}

#[derive(Clone, Debug)]
pub struct GameConfig {
    pub fixed_step: StepDuration,
    pub max_steps_per_frame: u32,
    pub initial_motion_policy: MotionPolicy,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MotionPolicy {
    Full,
    Reduced,
    Disabled,
}

Required constructors:

impl GameConfig {
    pub fn fixed_60hz() -> Self;
}

impl<G: Game> GameRuntime<G> {
    pub fn new(initial_state: G, config: GameConfig) -> Self;
    pub fn state(&self) -> &G;
    pub fn state_mut_for_tests(&mut self) -> &mut G;
    pub fn push_input(&mut self, input: HostInputEvent);
    pub fn advance_frame(&mut self, elapsed_seconds: f64) -> Scene2DFrame;
    pub fn snapshot_intent_ir(&self) -> Option<GameIntentIR>;
    pub fn snapshot_scene_ir(&self) -> Option<Scene2DIR>;
}

advance_frame must:

  1. Accumulate elapsed time.
  2. Convert host input events into game messages using Game::input mappings.
  3. Run at most max_steps_per_frame fixed simulation steps.
  4. Build the current GameIntentIR by calling Game::show.
  5. Lower and optimize into Scene2DIR.
  6. Return a Scene2DFrame suitable for rendering.

16.2 Fission widget host

The Fission integration must expose:

pub struct GameCanvas<G: Game> {
    pub id: WidgetId,
    pub initial_state: G,
    pub config: GameConfig,
}

Required API:

impl<G: Game> GameCanvas<G> {
    pub fn new(id: WidgetId, initial_state: G) -> Self;
    pub fn config(mut self, config: GameConfig) -> Self;
}

impl<G: Game> From<GameCanvas<G>> for Widget;

GameCanvas owns or references a GameRuntime<G> keyed by the widget id. On first mount, it initializes the runtime with initial_state. On subsequent frames, it forwards input, steps the runtime, and renders the returned Scene2DFrame through the Fission renderer.

17. Runtime pipeline

For each host frame, the runtime must execute this pipeline:

1. Capture host input events.
2. Translate input events into game messages through InputMap.
3. Append messages to the current tick's message queue.
4. While accumulator >= fixed_step and steps < max_steps_per_frame:
     a. Create GameCtx.
     b. Deliver queued messages to Game::react in capture order.
     c. Create StepCtx.
     d. Call Game::step.
     e. Increment tick.
     f. Subtract fixed_step from accumulator.
5. Create GameView.
6. Call Game::show.
7. Produce GameIntentIR.
8. Validate GameIntentIR.
9. Lower GameIntentIR to Scene2DIR.
10. Run required optimization passes.
11. Submit Scene2DFrame to renderer.

Game::step must never be called with a variable duration.

Game::show must not mutate game state.

18. Closed IR specification

The IR is internal but must be closed and inspectable.

18.1 GameIntentIR

#[derive(Clone, Debug)]
pub struct GameIntentIR {
    pub tick: Tick,
    pub fields: Vec<GameFieldMeta>,
    pub areas: Vec<AreaIntent>,
    pub visuals: Vec<VisualIntent>,
    pub text: Vec<TextIntent>,
    pub diagnostics: Vec<GameDiagnostic>,
}

18.2 IDs

#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct IntentId {
    pub symbol: StableSymbol,
}

Symbol generation rules:

singleton field
  <game_type_path>::<field_name>

object group item
  <game_type_path>::<field_name>#<stable_key_value>

object group item part
  <game_type_path>::<field_name>#<stable_key_value>::part::<part_key_value>

singleton object part
  <game_type_path>::<field_name>::part::<part_key_value>

The implementation must use generated stable key encodings. It must not use pointer addresses or randomized hashes.

18.3 AreaIntent

#[derive(Clone, Debug)]
pub struct AreaIntent {
    pub id: IntentId,
    pub bounds: Bounds2D,
    pub background: Option<Color>,
    pub layer: Layer,
}

18.4 VisualIntent

#[derive(Clone, Debug)]
pub struct VisualIntent {
    pub id: IntentId,
    pub transform: Transform2D,
    pub size: Option<Size>,
    pub look: Look,
    pub opacity: f32,
    pub visible: bool,
    pub layer: Layer,
    pub clip: Option<IntentId>,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transform2D {
    pub translation: Place,
    pub rotation: Degrees,
    pub scale_x: f32,
    pub scale_y: f32,
    pub anchor: Anchor,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Anchor {
    Center,
    TopLeft,
}

18.5 TextIntent

#[derive(Clone, Debug)]
pub struct TextIntent {
    pub id: IntentId,
    pub text: String,
    pub transform: Transform2D,
    pub size: Px,
    pub color: Color,
    pub opacity: f32,
    pub layer: Layer,
}

18.6 Scene2DIR

#[derive(Clone, Debug)]
pub struct Scene2DIR {
    pub tick: Tick,
    pub commands: Vec<Scene2DCommand>,
    pub diagnostics: Vec<GameDiagnostic>,
}

#[derive(Clone, Debug)]
pub enum Scene2DCommand {
    Clear {
        color: Color,
    },
    DrawRect {
        id: IntentId,
        bounds: Bounds2D,
        fill: Color,
        layer: Layer,
        opacity: f32,
    },
    DrawImage {
        id: IntentId,
        image: ImageAsset,
        transform: Transform2D,
        size: Size,
        layer: Layer,
        opacity: f32,
    },
    DrawText {
        id: IntentId,
        text: String,
        transform: Transform2D,
        size: Px,
        color: Color,
        layer: Layer,
        opacity: f32,
    },
    ImageBatch {
        image: ImageAsset,
        layer: Layer,
        instances: Vec<ImageInstance2D>,
    },
}

#[derive(Clone, Debug)]
pub struct ImageInstance2D {
    pub id: IntentId,
    pub transform: Transform2D,
    pub size: Size,
    pub opacity: f32,
}

The command set is closed. A backend renderer may lower these commands further, but application code must not inject arbitrary backend commands in the initial implementation.

19. Required lowering rules

19.1 Area lowering

view.area(field).size(size).background(color) must produce one AreaIntent.

If top_left_at is not called, top-left defaults to (0px, 0px).

If background is set, lowering to Scene2DIR must include a DrawRect or Clear command. If the area corresponds to the active camera/play area, Clear may be used.

19.2 Object lowering

view.show(field) must produce one VisualIntent with an ID derived from the field symbol.

If looks_like(ImageAsset) is used, lowering must produce DrawImage.

If looks_like(Color) or Look::Rect is used, lowering must produce DrawRect.

visible(false) suppresses rendering commands but keeps the intent in GameIntentIR for diagnostics.

19.3 Group lowering

view.show_each(field, closure) must iterate over field.values() in slice order.

For each item:

  1. Compute the stable key using field.key_of(item).
  2. Validate that the key is unique within the group for the current frame.
  3. Run the closure.
  4. Create visual intents for the item root and any parts declared by the closure.

19.4 Part lowering

item.part(part_key) must create an ID derived from:

group field symbol + item stable key + part stable key

A part is a presentation identity. It does not change gameplay identity.

19.5 Text lowering

view.text(field) must produce one TextIntent.

If .content(...) is called, that string is used. Otherwise DisplayText::display_text(field.value()) is used.

20. Required optimization passes

The implementation must run these passes in this order:

1. ValidationPass
2. StableOrderPass
3. VisibilityCullPass
4. AdjacentImageBatchPass
5. SceneCommandEmissionPass

20.1 ValidationPass

Must detect:

- duplicate keys in an objects field
- non-finite transform values
- negative sizes
- opacity outside [0, 1]
- missing image asset dimensions when required for rendering
- missing text size

Behavior:

debug/test builds
  validation errors fail the frame and are surfaced to the test harness.

release builds
  validation errors are recorded as diagnostics; invalid declarations are skipped.

For duplicate keys in release builds, the first item wins and later items with the same key are skipped. This rule is deterministic.

20.2 StableOrderPass

Must sort renderable intents by:

1. layer ascending
2. source order ascending
3. stable symbol ascending as a tie-breaker

Source order is the order in which declarations were made inside Game::show.

20.3 VisibilityCullPass

Must remove rendering commands whose bounds are fully outside the active area/camera bounds.

If no active area/camera is declared, no culling is performed.

Culling must not remove GameIntentIR entries; it only affects Scene2DIR commands.

20.4 AdjacentImageBatchPass

Must combine adjacent DrawImage commands into ImageBatch when all of the following are equal:

image
layer
clip
sampling mode
blend mode

The pass must not reorder transparent or overlapping images to create larger batches. It may only batch commands that are adjacent after StableOrderPass.

20.5 SceneCommandEmissionPass

Must convert optimized intents into closed Scene2DCommand values.

21. Diagnostics

#[derive(Clone, Debug)]
pub enum GameDiagnostic {
    DuplicateGroupKey {
        group: StableSymbol,
        key: StableKeyValue,
    },
    InvalidTransform {
        id: StableSymbol,
        reason: String,
    },
    InvalidSize {
        id: StableSymbol,
        size: Size,
    },
    InvalidOpacity {
        id: StableSymbol,
        opacity: f32,
    },
    MissingAsset {
        id: StableSymbol,
        asset: StableSymbol,
    },
    SkippedInvalidDeclaration {
        id: StableSymbol,
        reason: String,
    },
    OptimizationNote {
        id: Option<StableSymbol>,
        message: String,
    },
}

Diagnostics must be exposed through:

GameRuntime::snapshot_intent_ir()
GameRuntime::snapshot_scene_ir()
GameTestHarness diagnostics APIs

22. Testing API

The test harness must allow deterministic game tests without a visible window.

pub struct GameTestHarness<G: Game> {
    // implementation-private
}

Required API:

impl<G: Game> GameTestHarness<G> {
    pub fn new(initial_state: G) -> Self;
    pub fn with_config(initial_state: G, config: GameConfig) -> Self;

    pub fn state(&self) -> &G;
    pub fn state_mut(&mut self) -> &mut G;

    pub fn send(&mut self, message: G::Message);
    pub fn input(&mut self, input: HostInputEvent);

    pub fn step_ticks(&mut self, ticks: u64);
    pub fn render_once(&mut self);

    pub fn intent_ir(&self) -> &GameIntentIR;
    pub fn scene_ir(&self) -> &Scene2DIR;
    pub fn diagnostics(&self) -> &[GameDiagnostic];

    pub fn assert_no_diagnostics(&self);
}

Example:

#[test]
fn bird_pulls_up_after_tap() {
    let mut game = GameTestHarness::new(FlappyBird::new());

    let y0 = game.state().bird.place.y;

    game.send(FlappyMessage::PullBird);
    game.step_ticks(1);

    assert!(game.state().bird.place.y < y0);
}

Example IR test:

#[test]
fn pipes_are_batched() {
    let mut game = GameTestHarness::new(FlappyBird::new());

    game.render_once();
    game.assert_no_diagnostics();

    let batches = game
        .scene_ir()
        .commands
        .iter()
        .filter(|cmd| matches!(cmd, Scene2DCommand::ImageBatch { .. }))
        .count();

    assert!(batches >= 1);
}

23. Determinism rules

The runtime must enforce these rules:

  1. Game::step receives fixed time only.
  2. Game::step order is deterministic.
  3. Input messages are delivered in capture order.
  4. Field registry order is source field order.
  5. Group iteration order is slice order.
  6. Duplicate group keys are deterministic errors.
  7. Stable symbols must not use memory addresses.
  8. Stable symbols must not use randomized hash output.
  9. The same game state and input log must produce the same GameIntentIR and Scene2DIR, modulo floating-point tolerance.

The initial implementation may use f32 for geometry. Tests comparing geometry must use explicit tolerances.

24. Integration with MotionExpr

The 2D engine must not define a separate animation expression language.

Presentation-time animation should reuse the widget motion system's MotionExpr, MotionTrack, and MotionDefinition concepts where needed.

Initial required integration:

pub trait SupportsMotion2D {
    fn motion(self, motion: MotionDefinition) -> Self;
}

The following visual properties must be mappable to motion tracks:

opacity
translate_x
translate_y
scale
rotation

Motion remains explicit and opt-in. A game object shown without .motion(...) emits no motion declaration.

Simulation motion such as bird gravity or pipe movement remains ordinary Rust state update in Game::step.

25. Error cases that must fail to compile

The following must fail at compile time:

25.1 Text used as shape source

ctx.world().touches(self.score(), self.gaps());

Reason: TextField does not implement QueryShapeSource.

25.2 Object group without a stable key

#[derive(GameState)]
struct BadGame {
    #[game(objects)]
    enemies: Vec<Enemy>,
}

Reason: objects requires key = field_name.

25.3 Key field does not implement StableKey

struct Enemy {
    id: std::rc::Rc<String>,
}

#[derive(GameState)]
struct BadGame {
    #[game(objects, key = id)]
    enemies: Vec<Enemy>,
}

Reason: key type does not implement StableKey.

25.4 Area query with non-area field

ctx.world().outside(self.bird(), self.score());

Reason: TextField does not implement QueryAreaSource.

25.5 Wrong asset type as look

view.show(self.bird()).looks_like(assets::JumpSound);

Reason: sound assets do not convert into Look.

26. Required implementation milestones

The initial implementation must be delivered in these milestones.

Milestone 1: Core types and derives

Required:

Px, Place, Size, Bounds2D, Degrees, Color
StableKey trait and derive
GameState derive
ObjectField, ObjectsField, AreaField, TextField
GameFieldMeta registry

Milestone 2: Runtime and input

Required:

Game trait
InputMap
GameRuntime
GameConfig
fixed-step stepping
message delivery
GameCtx
StepCtx

Milestone 3: World queries

Required:

Touchable2D
Area2D
TouchArea
WorldQueries::touches
WorldQueries::outside
Circle/Rect/Union narrow phase
AABB broad phase for large shape sets

Milestone 4: Everyday rendering

Required:

GameView
area/show/show_each/text
object parts with typed StableKey part keys
Look
Layer
GameIntentIR

Milestone 5: Expert Scene2D

Required:

Scene2D
camera/sprite/sprites/text builders
raw_scene2d integration

Milestone 6: Lowering and optimization

Required:

ValidationPass
StableOrderPass
VisibilityCullPass
AdjacentImageBatchPass
SceneCommandEmissionPass
Scene2DIR

Milestone 7: Fission integration and tests

Required:

GameCanvas<G>
From<GameCanvas<G>> for Widget
GameTestHarness
IR snapshot tests
Flappy Bird example

27. Acceptance criteria

The implementation is complete when all of the following are true:

  1. The Flappy Bird example in this RFC compiles with only minor import/module adjustments.
  2. The example contains no authored string object IDs.
  3. self.bird(), self.gaps(), self.play_area(), and self.score() are generated by #[derive(GameState)].
  4. ctx.world().touches(self.bird(), self.gaps()) compiles and works.
  5. ctx.world().touches(self.score(), self.gaps()) fails to compile.
  6. view.show_each(self.gaps(), ...) lowers repeated pipe visuals into stable item/part IDs.
  7. Repeated pipe images are batched by AdjacentImageBatchPass when adjacent.
  8. Duplicate GapId values produce GameDiagnostic::DuplicateGroupKey.
  9. GameTestHarness can run the game headlessly.
  10. A fixed input log produces repeatable GameIntentIR and Scene2DIR snapshots.
  11. GameCanvas<FlappyBird> can be embedded in a Fission UI tree.
  12. Expert Scene2D declarations can be mixed into an everyday GameView via raw_scene2d.

28. Future extensions intentionally left outside this RFC

The following should be designed separately:

full physics
physics materials
advanced particles
sound/audio graph
tilemap-specific API
networked determinism
record/replay file format
visual game editor
3D Scene3D API
asset hot reload
shader authoring

The architecture in this RFC reserves room for these features through typed handles, stable symbols, closed IR, and explicit lowering passes.

29. Final design summary

The proposed Fission 2D engine has this shape:

Everyday authoring
  Rust structs + #[derive(GameState)] + generated field handles

Expert authoring
  Scene2D builder with game-engine vocabulary

Shared lowering
  GameIntentIR -> Scene2DIR

Runtime
  fixed-step simulation, deterministic input, typed world queries

Optimization
  validation, stable ordering, culling, batching, scene command emission

The developer writes:

view.show(self.bird())
ctx.world().touches(self.bird(), self.gaps())

not:

view.show("bird", &self.bird)
ctx.world().touches("bird", "gaps")

The result is a game API that feels like ordinary Rust while preserving the compiler-style lowering model that makes Fission distinct.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions