diff --git a/Cargo.lock b/Cargo.lock
index c63705224779e512ae342f11660d67e1358c5a31..adcc1f9debc8234b4d7d8ea91d8761682e3b6f8a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -530,6 +530,9 @@ name = "glam"
 version = "0.24.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42218cb640844e3872cc3c153dc975229e080a6c4733b34709ef445610550226"
+dependencies = [
+ "bytemuck",
+]
 
 [[package]]
 name = "glow"
@@ -1356,10 +1359,12 @@ name = "quickgame"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "bytemuck",
  "console_error_panic_hook",
  "console_log",
  "env_logger",
  "glam",
+ "instant",
  "log",
  "png",
  "pollster",
diff --git a/Cargo.toml b/Cargo.toml
index 8e00a5fc30092664617fa7bee31618d05ec46186..d0de8cedc9fc2467cef6e207644cb972c22941a2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,13 +23,15 @@ default = []
 webgl = ["wgpu/webgl"]
 
 [dependencies]
-glam = "0.24"
+glam = { version = "0.24", features = ["bytemuck"] }
 wgpu = "0.16"
 winit = "0.28"
 log = "0.4"
 anyhow = "1"
 reqwest = "0.11"
 png = "0.17"
+bytemuck = "1"
+instant = "0.1"
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 pollster = "0.3"
diff --git a/assets/sprite.wgsl b/assets/sprite.wgsl
new file mode 100644
index 0000000000000000000000000000000000000000..d2f610f85019d3002669c28cb9bfb7f168a58918
--- /dev/null
+++ b/assets/sprite.wgsl
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7c5f9bc276726e746c1c09b5f74d8e8a4784927646e1698aab241e2d778ee2ad
+size 772
diff --git a/src/anim.rs b/src/anim.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d7eba974f46e29d8c95197c1e8f5127fd4311982
--- /dev/null
+++ b/src/anim.rs
@@ -0,0 +1,42 @@
+use crate::prelude::*;
+
+pub struct Animation<T> {
+    keyframes: Vec<T>,
+    begin: Instant,
+    duration: Duration,
+}
+
+impl<T> Animation<T> {
+    pub fn new(keyframes: Vec<T>, duration: Duration) -> Self {
+        Self {
+            keyframes,
+            begin: Instant::now(),
+            duration,
+        }
+    }
+
+    pub fn get(&self) -> T
+    where
+        T: std::ops::Mul<f32, Output = T> + std::ops::Add<Output = T> + Clone,
+    {
+        let prog = self.begin.elapsed().as_secs_f32() / self.duration.as_secs_f32();
+        let scale = prog * self.keyframes.len() as f32;
+        let low = scale.floor() as usize;
+        let high = scale.ceil() as usize;
+        let blend = scale.fract();
+
+        match (self.keyframes.get(low), self.keyframes.get(high)) {
+            (None, None) => self.keyframes.last().unwrap().clone(),
+            (None, Some(end)) | (Some(end), None) => end.clone(),
+            (Some(a), Some(b)) => a.clone() * (1.0 - blend) + b.clone() * blend,
+        }
+    }
+
+    pub fn complete(&self) -> bool {
+        self.begin.elapsed() >= self.duration
+    }
+
+    pub fn reset(&mut self) {
+        self.begin = Instant::now();
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index c805a5bd8550d8b4e3f1cda375d990b27f75b58f..65dfc048385ba8b229f037a32205ed679e74ee03 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,8 @@
+mod anim;
 mod asset;
 pub mod prelude;
+mod sprite;
+mod state;
 
 use wgpu::*;
 use winit::{
@@ -9,7 +12,7 @@ use winit::{
     window::Window,
 };
 
-use crate::prelude::*;
+use crate::{prelude::*, state::State};
 
 pub async fn run() -> Result<()> {
     let event_loop = winit::event_loop::EventLoop::new();
@@ -55,7 +58,7 @@ pub async fn run() -> Result<()> {
             &DeviceDescriptor {
                 label: Some("GMTK"),
                 features: Features::empty(),
-                limits: Limits::downlevel_webgl2_defaults(),
+                limits: Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
             },
             None,
         )
@@ -105,10 +108,7 @@ pub async fn run() -> Result<()> {
         view_formats: &[],
     });
 
-    let fg = asset::load_png("splash_fg.png").await?;
-    let bg = asset::load_png("splash_bg.png").await?;
-
-    let _ = (fg, bg);
+    let mut state = State::new(&queue, &device).await?;
 
     event_loop.run(move |event, _target, control_flow| {
         *control_flow = ControlFlow::Poll;
@@ -177,35 +177,45 @@ pub async fn run() -> Result<()> {
                     label: Some("Main encoder"),
                 });
 
-                enc.begin_render_pass(&RenderPassDescriptor {
-                    label: Some("Clear"),
-                    color_attachments: &[Some(RenderPassColorAttachment {
-                        view: &framebuffer.create_view(&TextureViewDescriptor {
-                            label: Some("Antialias target"),
-                            format: view_formats.get(0).copied(),
-                            ..Default::default()
-                        }),
-                        resolve_target: Some(&swapchain.texture.create_view(
-                            &TextureViewDescriptor {
-                                label: Some("Resolve target"),
-                                format: view_formats.get(0).copied(),
-                                ..Default::default()
+                {
+                    let fb_view = framebuffer.create_view(&TextureViewDescriptor {
+                        label: Some("Antialias target"),
+                        format: view_formats.get(0).copied(),
+                        ..Default::default()
+                    });
+                    let rs_view = swapchain.texture.create_view(&TextureViewDescriptor {
+                        label: Some("Resolve target"),
+                        format: view_formats.get(0).copied(),
+                        ..Default::default()
+                    });
+                    let db_view = depthbuffer.create_view(&Default::default());
+
+                    let mut pass = enc.begin_render_pass(&RenderPassDescriptor {
+                        label: Some("Clear"),
+                        color_attachments: &[Some(RenderPassColorAttachment {
+                            view: &fb_view,
+                            resolve_target: Some(&rs_view),
+                            ops: Operations {
+                                load: LoadOp::Clear(Color::BLACK),
+                                store: true,
                             },
-                        )),
-                        ops: Operations {
-                            load: LoadOp::Clear(Color::RED),
-                            store: true,
-                        },
-                    })],
-                    depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
-                        view: &depthbuffer.create_view(&Default::default()),
-                        depth_ops: Some(Operations {
-                            load: LoadOp::Clear(1.0),
-                            store: true,
+                        })],
+                        depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
+                            view: &db_view,
+                            depth_ops: Some(Operations {
+                                load: LoadOp::Clear(1.0),
+                                store: true,
+                            }),
+                            stencil_ops: None,
                         }),
-                        stencil_ops: None,
-                    }),
-                });
+                    });
+
+                    let pipeline_fmt = view_formats.get(0).copied().unwrap_or(format);
+                    let aspect =
+                        window.inner_size().width as f32 / window.inner_size().height as f32;
+
+                    state.update(&queue, &device, &mut pass, pipeline_fmt, aspect);
+                }
 
                 queue.submit([enc.finish()]);
 
diff --git a/src/prelude.rs b/src/prelude.rs
index 665f0a5f5a0e2fabebe8d72089a693ac7e47e916..37546a48f147e13e1911c6e11087a09065652b46 100644
--- a/src/prelude.rs
+++ b/src/prelude.rs
@@ -1,3 +1,26 @@
-pub use anyhow::{Result, anyhow};
-pub use glam::{Vec2, Vec3};
+pub use crate::{
+    anim::Animation,
+    asset::{load, load_png},
+    sprite::Sprite,
+};
+pub use anyhow::{anyhow, Result};
+pub use glam::{Mat4, Quat, Vec2, Vec3};
+pub use instant::{Duration, Instant};
 pub use log::{debug, error, info, trace, warn};
+pub use wgpu::{
+    util::*,
+    {Device, Queue},
+};
+pub use std::f32::consts::{FRAC_PI_2, PI};
+
+pub trait Mat4Ext {
+    fn from_2d(position: Vec2, scale: f32, rotation: f32) -> Mat4 {
+        Mat4::from_scale_rotation_translation(
+            Vec3::splat(scale),
+            Quat::from_rotation_z(rotation),
+            Vec3::new(position.x, position.y, 0.0),
+        )
+    }
+}
+
+impl Mat4Ext for Mat4 {}
diff --git a/src/sprite.rs b/src/sprite.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9845035b978c454fc15e3302739a031d6db0200c
--- /dev/null
+++ b/src/sprite.rs
@@ -0,0 +1,183 @@
+use crate::prelude::*;
+use wgpu::*;
+
+pub struct Sprite {
+    layout: BindGroupLayout,
+    texture: Texture,
+    uniforms: Buffer,
+    bindgroup: BindGroup,
+    pub transforms: Mat4,
+}
+
+impl Sprite {
+    pub async fn new(name: &str, queue: &Queue, device: &Device, smooth: bool) -> Result<Self> {
+        let img = load_png(name).await?;
+
+        let label = format!("Sprite {name}");
+        let label = Some(label.as_str());
+
+        let texture = device.create_texture_with_data(
+            queue,
+            &TextureDescriptor {
+                label,
+                size: Extent3d {
+                    width: img.width,
+                    height: img.height,
+                    depth_or_array_layers: 1,
+                },
+                mip_level_count: 1,
+                sample_count: 1,
+                dimension: TextureDimension::D2,
+                format: TextureFormat::Rgba8Unorm,
+                usage: TextureUsages::TEXTURE_BINDING,
+                view_formats: &[],
+            },
+            &img.rgba,
+        );
+
+        let uniforms = device.create_buffer(&BufferDescriptor {
+            label,
+            size: 16 * 4,
+            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+
+        let layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
+            label,
+            entries: &[
+                BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: ShaderStages::VERTEX,
+                    ty: BindingType::Buffer {
+                        ty: BufferBindingType::Uniform,
+                        has_dynamic_offset: false,
+                        min_binding_size: None,
+                    },
+                    count: None,
+                },
+                BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: ShaderStages::FRAGMENT,
+                    ty: BindingType::Texture {
+                        sample_type: TextureSampleType::Float { filterable: true },
+                        view_dimension: TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+                BindGroupLayoutEntry {
+                    binding: 2,
+                    visibility: ShaderStages::FRAGMENT,
+                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
+                    count: None,
+                },
+            ],
+        });
+
+        let filter = smooth
+            .then_some(FilterMode::Linear)
+            .unwrap_or(FilterMode::Nearest);
+        let sampler = device.create_sampler(&SamplerDescriptor {
+            label,
+            mag_filter: filter,
+            min_filter: filter,
+            ..Default::default()
+        });
+
+        let bindgroup = device.create_bind_group(&BindGroupDescriptor {
+            label,
+            layout: &layout,
+            entries: &[
+                BindGroupEntry {
+                    binding: 0,
+                    resource: uniforms.as_entire_binding(),
+                },
+                BindGroupEntry {
+                    binding: 1,
+                    resource: BindingResource::TextureView(
+                        &texture.create_view(&Default::default()),
+                    ),
+                },
+                BindGroupEntry {
+                    binding: 2,
+                    resource: BindingResource::Sampler(&sampler),
+                },
+            ],
+        });
+
+        let transforms = Mat4::IDENTITY;
+
+        Ok(Self {
+            layout,
+            texture,
+            uniforms,
+            bindgroup,
+            transforms,
+        })
+    }
+
+    pub fn record<'a, 'b: 'a>(
+        &'b self,
+        queue: &Queue,
+        device: &Device,
+        pass: &mut RenderPass<'a>,
+        format: TextureFormat,
+        aspect: f32,
+    ) {
+        let mat = Mat4::from_scale(Vec3::new(1.0 / aspect, 1.0, 1.0)) * self.transforms;
+
+        queue.write_buffer(
+            &self.uniforms,
+            0,
+            bytemuck::bytes_of(&mat),
+        );
+
+        static PIPELINE: std::sync::OnceLock<RenderPipeline> = std::sync::OnceLock::new();
+        let pipeline = PIPELINE.get_or_init(|| {
+            let module = device.create_shader_module(include_wgsl!("../assets/sprite.wgsl"));
+
+            let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
+                label: Some("Sprite"),
+                bind_group_layouts: &[&self.layout],
+                push_constant_ranges: &[],
+            });
+
+            device.create_render_pipeline(&RenderPipelineDescriptor {
+                label: Some("Sprite"),
+                layout: Some(&layout),
+                vertex: VertexState {
+                    module: &module,
+                    entry_point: "vs_main",
+                    buffers: &[],
+                },
+                primitive: Default::default(),
+                depth_stencil: Some(DepthStencilState {
+                    format: TextureFormat::Depth24Plus,
+                    depth_write_enabled: false,
+                    depth_compare: CompareFunction::Always,
+                    stencil: Default::default(),
+                    bias: Default::default(),
+                }),
+                multisample: MultisampleState {
+                    count: 4,
+                    mask: 0xFF,
+                    alpha_to_coverage_enabled: false,
+                },
+                fragment: Some(FragmentState {
+                    module: &module,
+                    entry_point: "fs_main",
+                    targets: &[Some(ColorTargetState {
+                        format,
+                        blend: Some(BlendState::ALPHA_BLENDING),
+                        write_mask: ColorWrites::all(),
+                    })],
+                }),
+                multiview: None,
+            })
+        });
+
+        pass.set_pipeline(pipeline);
+        pass.set_bind_group(0, &self.bindgroup, &[]);
+        pass.draw(0..6, 0..1);
+    }
+}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f7379ffdd69d1ebb89f425d67e19e123beb689af
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,44 @@
+use wgpu::{RenderPass, TextureFormat};
+
+use crate::prelude::*;
+
+pub enum State {
+    Loading {
+        fg: Sprite,
+        bg: Sprite,
+        scale: Animation<f32>,
+        rot: Animation<f32>,
+    },
+}
+
+impl State {
+    pub async fn new(queue: &Queue, device: &Device) -> Result<State> {
+        Ok(State::Loading {
+            fg: Sprite::new("splash_fg.png", queue, device, true).await?,
+            bg: Sprite::new("splash_bg.png", queue, device, true).await?,
+            scale: Animation::new(vec![0.0, 0.1, 0.25], Duration::from_secs(1)),
+            rot: Animation::new(vec![PI, PI, 0.0], Duration::from_secs(3)),
+        })
+    }
+
+    pub fn update<'a, 'b: 'a>(
+        &'b mut self,
+        queue: &Queue,
+        device: &Device,
+        pass: &mut RenderPass<'a>,
+        format: TextureFormat,
+        aspect: f32,
+    ) {
+        match self {
+            State::Loading { fg, bg, scale, rot } => {
+                bg.transforms = Mat4::from_2d(Vec2::ZERO, scale.get(), rot.get());
+                fg.transforms = Mat4::from_2d(Vec2::ZERO, 0.25, 0.0);
+
+                bg.record(queue, device, pass, format, aspect);
+                if scale.complete() {
+                    fg.record(queue, device, pass, format, aspect)
+                }
+            }
+        }
+    }
+}