- 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
- Provide a Rust-native 2D game API that is approachable to non-game developers.
- Avoid authored string identifiers for game objects, object groups, areas, text, parts, assets, queries, and scene nodes.
- Use Rust types, fields, derives, and generated typed handles as the public identity mechanism.
- Provide an expert
Scene2D API for developers who prefer standard game-engine vocabulary.
- Lower both APIs into a shared closed IR.
- Support deterministic fixed-step simulation.
- Support deterministic input mapping, replay, and headless tests.
- Support visual rendering of images, shapes, text, repeated objects, and object parts.
- Support basic spatial queries using everyday terminology:
touches, outside, and related helpers.
- Provide enough structure for batching, culling, stable identity, and diagnostics.
- Allow everyday and expert APIs to be mixed in the same game.
- 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:
- A full physics engine.
- A public ECS model.
- 3D rendering.
- Multiplayer/network replication.
- Skeletal animation.
- Shader authoring.
- Tilemap-specific authoring syntax.
- Audio graph design.
- A visual editor.
- 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:
- Accumulate elapsed time.
- Convert host input events into game messages using
Game::input mappings.
- Run at most
max_steps_per_frame fixed simulation steps.
- Build the current
GameIntentIR by calling Game::show.
- Lower and optimize into
Scene2DIR.
- 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:
- Compute the stable key using
field.key_of(item).
- Validate that the key is unique within the group for the current frame.
- Run the closure.
- 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:
Game::step receives fixed time only.
Game::step order is deterministic.
- Input messages are delivered in capture order.
- Field registry order is source field order.
- Group iteration order is slice order.
- Duplicate group keys are deterministic errors.
- Stable symbols must not use memory addresses.
- Stable symbols must not use randomized hash output.
- 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:
- The Flappy Bird example in this RFC compiles with only minor import/module adjustments.
- The example contains no authored string object IDs.
self.bird(), self.gaps(), self.play_area(), and self.score() are generated by #[derive(GameState)].
ctx.world().touches(self.bird(), self.gaps()) compiles and works.
ctx.world().touches(self.score(), self.gaps()) fails to compile.
view.show_each(self.gaps(), ...) lowers repeated pipe visuals into stable item/part IDs.
- Repeated pipe images are batched by
AdjacentImageBatchPass when adjacent.
- Duplicate
GapId values produce GameDiagnostic::DuplicateGroupKey.
GameTestHarness can run the game headlessly.
- A fixed input log produces repeatable
GameIntentIR and Scene2DIR snapshots.
GameCanvas<FlappyBird> can be embedded in a Fission UI tree.
- 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.
1. Summary
This RFC proposes a 2D game engine layer for Fission with two public authoring tiers and one shared closed implementation target.
The everyday API is designed for a developer who thinks in terms like
Bird,PipeGap,FlightPath,pull_bird, andscore, notEntity,Component,System,Sprite,Collider, orRenderPass.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
Scene2DAPI for developers who prefer standard game-engine vocabulary.touches,outside, and related helpers.3. Non-goals
The initial 2D engine specified here does not include:
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:
Terms intentionally avoided in the everyday API:
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:
The canonical form is:
The
self.bird()andself.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:
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
Touchable2DandArea2Dtraits on domain objects. Rendering usesGameVieworScene2Ddeclarations.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:
This tells the lowerer:
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:
The top-level
fissioncrate should re-export the public APIs as:The everyday API must not require users to import
fission_game_irdirectly.6. Core public types
6.1 Scalar and geometry types
The initial implementation must provide these types:
Required constructors:
Arithmetic for
Pxmust be implemented for addition, subtraction, multiplication byf32, and division byf32.6.2 Time types
Simulation must use fixed
StepDurationvalues supplied byGameConfig.7. Stable identity
7.1 Stable symbols
The implementation must define an internal stable symbol type:
StableSymbolis used internally for IR identity, diagnostics, snapshots, replay logs, and test output.User code must not be required to construct
StableSymbolfor normal gameplay objects.7.2 Stable keys
Repeated object groups require stable item keys.
The implementation must provide
StableKeyfor:The implementation must not use Rust's
Hashoutput to generate stable IDs. Rust hash output is not stable across processes.A derive macro must be provided:
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:
The attribute names are normative.
thingmust not be used. The public term isobject.slotmust not be used in user-facing API. The public term for generated identity-carrying values isfield handle.8.2 Generated field handles
Given:
The derive macro must generate methods equivalent to:
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:
which generates:
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:
Required methods:
The handle types may implement
CloneandCopywhen their fields permit it. Their public behavior must not require allocation.8.4 Generated field registry
The derive macro must implement:
Ignored fields must not appear in the field registry.
9. Everyday gameplay traits
9.1 Touchable objects
Spatial touch queries use
Touchable2D.TouchAreamust be:Required constructors:
The initial implementation must support exact narrow-phase tests for:
Edges touching count as touching.
9.2 Areas
Bounded areas use
Area2D.9.3 Text display
Text fields use
DisplayText.The implementation may provide a blanket implementation for
T: std::fmt::Display.10. The
GametraitThe everyday game trait is:
10.1 Input mapping
Required API:
Convenience constants or functions should be provided:
Example:
10.2 Context types
Required
StepCtxAPI:speed(PxPerSecond(v))returnsPx(v * self.dt().seconds as f32).GameCtxis 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.
11.1 Query source traits
The implementation must implement
QueryShapeSourcefor:and:
The implementation must implement
QueryAreaSourcefor:TextFieldmust not implementQueryShapeSourceorQueryAreaSource.Therefore this must not compile:
11.2 Required query methods
Semantics:
The implementation must use AABB broad-phase pruning before narrow-phase tests when either side contains more than eight shapes.
12. Everyday rendering API:
GameViewGameViewbuilds a frame-localGameIntentIR. It is called fromGame::show.Required methods:
12.1 Area builder
Required methods:
12.2 Object builder
Required methods:
center_atsets the visual center.top_left_atsets the visual top-left. If neither is called, the default place is(0px, 0px)with center anchoring.12.3 Object item builder
ObjectItemViewis used insideshow_each.Required methods:
The direct item methods configure the root item visual.
partconfigures a typed visual part of the item.12.4 Part builder
Required methods:
12.5 Text builder
Required methods:
If
contentis not called, the builder usesDisplayText::display_textfrom theTextFieldvalue.12.6 Look and layer
Required conversions:
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:
The macro must generate typed handles:
All image handles must have type
ImageAssetor a zero-sized type convertible intoImageAsset.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.
15. Expert API:
Scene2DThe expert API may use standard game-engine vocabulary. It must still use typed field handles and typed asset handles.
Required methods:
Required builders:
Required
Camera2DBuildermethods:Required
Sprite2DBuildermethods:Required
SpriteItem2DBuildermethods:Required
Text2DBuildermethods:15.1 Mixing everyday and expert API
Everyday games may use
raw_scene2dfor a local expert section:raw_scene2dis not an opaque draw callback. It emits structuredScene2Ddeclarations 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
Required constructors:
advance_framemust:Game::inputmappings.max_steps_per_framefixed simulation steps.GameIntentIRby callingGame::show.Scene2DIR.Scene2DFramesuitable for rendering.16.2 Fission widget host
The Fission integration must expose:
Required API:
GameCanvasowns or references aGameRuntime<G>keyed by the widgetid. On first mount, it initializes the runtime withinitial_state. On subsequent frames, it forwards input, steps the runtime, and renders the returnedScene2DFramethrough the Fission renderer.17. Runtime pipeline
For each host frame, the runtime must execute this pipeline:
Game::stepmust never be called with a variable duration.Game::showmust not mutate game state.18. Closed IR specification
The IR is internal but must be closed and inspectable.
18.1 GameIntentIR
18.2 IDs
Symbol generation rules:
The implementation must use generated stable key encodings. It must not use pointer addresses or randomized hashes.
18.3 AreaIntent
18.4 VisualIntent
18.5 TextIntent
18.6 Scene2DIR
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 oneAreaIntent.If
top_left_atis not called, top-left defaults to(0px, 0px).If
backgroundis set, lowering toScene2DIRmust include aDrawRectorClearcommand. If the area corresponds to the active camera/play area,Clearmay be used.19.2 Object lowering
view.show(field)must produce oneVisualIntentwith an ID derived from the field symbol.If
looks_like(ImageAsset)is used, lowering must produceDrawImage.If
looks_like(Color)orLook::Rectis used, lowering must produceDrawRect.visible(false)suppresses rendering commands but keeps the intent inGameIntentIRfor diagnostics.19.3 Group lowering
view.show_each(field, closure)must iterate overfield.values()in slice order.For each item:
field.key_of(item).19.4 Part lowering
item.part(part_key)must create an ID derived from:A part is a presentation identity. It does not change gameplay identity.
19.5 Text lowering
view.text(field)must produce oneTextIntent.If
.content(...)is called, that string is used. OtherwiseDisplayText::display_text(field.value())is used.20. Required optimization passes
The implementation must run these passes in this order:
20.1 ValidationPass
Must detect:
Behavior:
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:
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
GameIntentIRentries; it only affectsScene2DIRcommands.20.4 AdjacentImageBatchPass
Must combine adjacent
DrawImagecommands intoImageBatchwhen all of the following are equal: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
Scene2DCommandvalues.21. Diagnostics
Diagnostics must be exposed through:
22. Testing API
The test harness must allow deterministic game tests without a visible window.
Required API:
Example:
Example IR test:
23. Determinism rules
The runtime must enforce these rules:
Game::stepreceives fixed time only.Game::steporder is deterministic.GameIntentIRandScene2DIR, modulo floating-point tolerance.The initial implementation may use
f32for 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, andMotionDefinitionconcepts where needed.Initial required integration:
The following visual properties must be mappable to motion tracks:
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
Reason:
TextFielddoes not implementQueryShapeSource.25.2 Object group without a stable key
Reason:
objectsrequireskey = field_name.25.3 Key field does not implement
StableKeyReason: key type does not implement
StableKey.25.4 Area query with non-area field
Reason:
TextFielddoes not implementQueryAreaSource.25.5 Wrong asset type as look
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:
Milestone 2: Runtime and input
Required:
Milestone 3: World queries
Required:
Milestone 4: Everyday rendering
Required:
Milestone 5: Expert Scene2D
Required:
Milestone 6: Lowering and optimization
Required:
Milestone 7: Fission integration and tests
Required:
27. Acceptance criteria
The implementation is complete when all of the following are true:
self.bird(),self.gaps(),self.play_area(), andself.score()are generated by#[derive(GameState)].ctx.world().touches(self.bird(), self.gaps())compiles and works.ctx.world().touches(self.score(), self.gaps())fails to compile.view.show_each(self.gaps(), ...)lowers repeated pipe visuals into stable item/part IDs.AdjacentImageBatchPasswhen adjacent.GapIdvalues produceGameDiagnostic::DuplicateGroupKey.GameTestHarnesscan run the game headlessly.GameIntentIRandScene2DIRsnapshots.GameCanvas<FlappyBird>can be embedded in a Fission UI tree.Scene2Ddeclarations can be mixed into an everydayGameViewviaraw_scene2d.28. Future extensions intentionally left outside this RFC
The following should be designed separately:
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:
The developer writes:
not:
The result is a game API that feels like ordinary Rust while preserving the compiler-style lowering model that makes Fission distinct.