diff --git a/Cargo.lock b/Cargo.lock
index 2972394..169b446 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -904,6 +904,17 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "freetype-rs"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5442dee36ca09604133580dc0553780e867936bb3cbef3275859e889026d2b17"
+dependencies = [
+ "bitflags 2.5.0",
+ "freetype-sys",
+ "libc",
+]
+
 [[package]]
 name = "freetype-sys"
 version = "0.20.1"
@@ -2672,6 +2683,7 @@ version = "0.1.0"
 dependencies = [
  "chrono",
  "edit-distance",
+ "freetype-rs",
  "image 0.25.1",
  "kd-tree",
  "num",
diff --git a/Cargo.toml b/Cargo.toml
index c5e8f46..5b23459 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,10 +6,11 @@ edition = "2021"
 [dependencies]
 chrono = "0.4.38"
 edit-distance = "2.1.0"
+freetype-rs = "0.36.0"
 image = "0.25.1"
 kd-tree = { version="0.6.0", features=["serde"] }
 num = "0.4.3"
-plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c" }
+plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c", features=["bitmap_backend"] }
 poise = "0.6.1"
 postcard = { version="1.0.8", features=["use-std"] }
 serde = "1.0.204"
@@ -19,5 +20,5 @@ tesseract = "0.15.1"
 tokio = {version="1.38.0", features=["rt-multi-thread"]}
 typenum = "1.17.0"
 
-[profile.dev.package.sqlx-macros]
+[profile.dev.package."*"]
 opt-level = 3
diff --git a/data/assets/count_background.png b/data/assets/count_background.png
new file mode 100644
index 0000000..aed324d
Binary files /dev/null and b/data/assets/count_background.png differ
diff --git a/data/assets/diff-byd.png b/data/assets/diff-byd.png
new file mode 100644
index 0000000..22b9eff
Binary files /dev/null and b/data/assets/diff-byd.png differ
diff --git a/data/assets/diff-etr.png b/data/assets/diff-etr.png
new file mode 100644
index 0000000..1671368
Binary files /dev/null and b/data/assets/diff-etr.png differ
diff --git a/data/assets/diff-ftr.png b/data/assets/diff-ftr.png
new file mode 100644
index 0000000..af5b3d5
Binary files /dev/null and b/data/assets/diff-ftr.png differ
diff --git a/data/assets/diff-prs.png b/data/assets/diff-prs.png
new file mode 100644
index 0000000..0265965
Binary files /dev/null and b/data/assets/diff-prs.png differ
diff --git a/data/assets/diff-pst.png b/data/assets/diff-pst.png
new file mode 100644
index 0000000..e57182f
Binary files /dev/null and b/data/assets/diff-pst.png differ
diff --git a/src/bitmap.rs b/src/bitmap.rs
new file mode 100644
index 0000000..acac317
--- /dev/null
+++ b/src/bitmap.rs
@@ -0,0 +1,411 @@
+use freetype::{
+	face::{KerningMode, LoadFlag},
+	ffi::FT_GLYPH_BBOX_PIXELS,
+	Face,
+};
+use num::traits::Euclid;
+
+use crate::context::Error;
+
+// {{{ BitmapCanvas
+pub struct BitmapCanvas {
+	pub buffer: Box<[u8]>,
+	pub width: u32,
+}
+
+impl BitmapCanvas {
+	// {{{ Draw pixel
+	pub fn set_pixel(&mut self, pos: (u32, u32), color: (u8, u8, u8, u8)) {
+		let index = 3 * (pos.1 * self.width + pos.0) as usize;
+		let alpha = color.3 as u32;
+		self.buffer[index + 0] =
+			((alpha * color.0 as u32 + (255 - alpha) * self.buffer[index + 0] as u32) / 255) as u8;
+		self.buffer[index + 1] =
+			((alpha * color.1 as u32 + (255 - alpha) * self.buffer[index + 1] as u32) / 255) as u8;
+		self.buffer[index + 2] =
+			((alpha * color.2 as u32 + (255 - alpha) * self.buffer[index + 2] as u32) / 255) as u8;
+	}
+	// }}}
+	// {{{ Draw RBG image
+	/// Draws a bitmap image
+	pub fn blit_rbg(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), src: &[u8]) {
+		let height = self.buffer.len() as u32 / 3 / self.width;
+		for dx in 0..iw {
+			for dy in 0..ih {
+				let x = pos.0 + dx as i32;
+				let y = pos.1 + dy as i32;
+				if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height {
+					let r = src[(dx + dy * iw) as usize * 3];
+					let g = src[(dx + dy * iw) as usize * 3 + 1];
+					let b = src[(dx + dy * iw) as usize * 3 + 2];
+
+					let color = (r, g, b, 255);
+
+					self.set_pixel((x as u32, y as u32), color);
+				}
+			}
+		}
+	}
+	// }}}
+	// {{{ Draw RGBA image
+	/// Draws a bitmap image taking care of the alpha channel.
+	pub fn blit_rbga(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), src: &[u8]) {
+		let height = self.buffer.len() as u32 / 3 / self.width;
+		for dx in 0..iw {
+			for dy in 0..ih {
+				let x = pos.0 + dx as i32;
+				let y = pos.1 + dy as i32;
+				if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height {
+					let r = src[(dx + dy * iw) as usize * 4];
+					let g = src[(dx + dy * iw) as usize * 4 + 1];
+					let b = src[(dx + dy * iw) as usize * 4 + 2];
+					let a = src[(dx + dy * iw) as usize * 4 + 3];
+
+					let color = (r, g, b, a);
+
+					self.set_pixel((x as u32, y as u32), color);
+				}
+			}
+		}
+	}
+	// }}}
+	// {{{ Fill
+	/// Fill with solid color
+	pub fn fill(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), color: (u8, u8, u8, u8)) {
+		let height = self.buffer.len() as u32 / 3 / self.width;
+		for dx in 0..iw {
+			for dy in 0..ih {
+				let x = pos.0 + dx as i32;
+				let y = pos.1 + dy as i32;
+				if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height {
+					self.set_pixel((x as u32, y as u32), color);
+				}
+			}
+		}
+	}
+	// }}}
+	// {{{ Draw text
+	/// Render text
+	pub fn text(
+		&mut self,
+		pos: (i32, i32),
+		face: Face,
+		size: u32,
+		text: &str,
+		color: (u8, u8, u8, u8),
+	) -> Result<(), Error> {
+		face.set_char_size(0, (size as isize) << 6, 300, 300)?;
+
+		let mut pen_x = 0;
+		let kerning = face.has_kerning();
+		let mut previous = None;
+		let mut data = Vec::new();
+
+		for c in text.chars() {
+			let glyph_index = face
+				.get_char_index(c as usize)
+				.ok_or_else(|| format!("Could not get glyph index for char {:?}", c))?;
+
+			if let Some(previous) = previous
+				&& kerning
+			{
+				let delta = face.get_kerning(previous, glyph_index, KerningMode::KerningDefault)?;
+				pen_x += delta.x >> 6; // we shift to get rid of sub-pixel accuracy
+			}
+
+			face.load_glyph(glyph_index, LoadFlag::DEFAULT)?;
+
+			data.push((pen_x, face.glyph().get_glyph()?));
+			pen_x += face.glyph().advance().x >> 6;
+			previous = Some(glyph_index);
+		}
+
+		let mut x_min = 32000;
+		let mut y_min = 32000;
+		let mut x_max = -32000;
+		let mut y_max = -32000;
+
+		for (pen_x, glyph) in &data {
+			let mut bbox = glyph.get_cbox(FT_GLYPH_BBOX_PIXELS);
+
+			bbox.xMin += pen_x;
+			bbox.xMax += pen_x;
+
+			if bbox.xMin < x_min {
+				x_min = bbox.xMin
+			}
+
+			if bbox.xMax < x_max {
+				x_max = bbox.xMax
+			}
+
+			if bbox.yMin < y_min {
+				y_min = bbox.yMin
+			}
+
+			if bbox.yMax < y_max {
+				y_max = bbox.yMax
+			}
+		}
+
+		// Check that we really grew the string bbox
+		if x_min > x_max {
+			x_min = 0;
+			x_max = 0;
+			y_min = 0;
+			y_max = 0;
+		}
+
+		for (pos_x, glyph) in &data {
+			let b_glyph = glyph.to_bitmap(freetype::RenderMode::Normal, None)?;
+			let bitmap = b_glyph.bitmap();
+			let pixel_mode = bitmap.pixel_mode()?;
+			println!(
+				"Pixel mode: {:?}, width {:?}, height {:?}, len {:?}, pen x {:?}",
+				pixel_mode,
+				bitmap.width(),
+				bitmap.rows(),
+				bitmap.buffer().len(),
+				pos_x
+			);
+		}
+
+		Ok(())
+	}
+	// }}}
+
+	#[inline]
+	pub fn new(width: u32, height: u32) -> Self {
+		let buffer = vec![u8::MAX; 8 * 3 * (width * height) as usize].into_boxed_slice();
+		Self { buffer, width }
+	}
+}
+// }}}
+// {{{ Layout types
+#[derive(Clone, Copy, Debug)]
+pub struct LayoutBox {
+	relative_to: Option<(LayoutBoxId, i32, i32)>,
+	pub width: u32,
+	pub height: u32,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub struct LayoutBoxId(usize);
+
+#[derive(Default, Debug)]
+pub struct LayoutManager {
+	boxes: Vec<LayoutBox>,
+}
+
+pub struct LayoutDrawer {
+	pub layout: LayoutManager,
+	pub canvas: BitmapCanvas,
+}
+
+impl LayoutManager {
+	// {{{ Trivial box creation
+	pub fn make_box(&mut self, width: u32, height: u32) -> LayoutBoxId {
+		let id = self.boxes.len();
+		self.boxes.push(LayoutBox {
+			relative_to: None,
+			width,
+			height,
+		});
+
+		LayoutBoxId(id)
+	}
+
+	pub fn make_relative_box(
+		&mut self,
+		to: LayoutBoxId,
+		x: i32,
+		y: i32,
+		width: u32,
+		height: u32,
+	) -> LayoutBoxId {
+		let id = self.make_box(width, height);
+		self.edit_to_relative(id, to, x, y);
+
+		id
+	}
+	// }}}
+	// {{{ Chage box to be relative
+	pub fn edit_to_relative(
+		&mut self,
+		id: LayoutBoxId,
+		id_relative_to: LayoutBoxId,
+		x: i32,
+		y: i32,
+	) {
+		let current = self.boxes[id.0];
+		let to = self.boxes[id_relative_to.0];
+		if let Some((current_points_to, dx, dy)) = current.relative_to
+			&& current_points_to != id_relative_to
+		{
+			self.edit_to_relative(current_points_to, id_relative_to, x - dx, y - dy);
+		} else {
+			self.boxes[id.0].relative_to = Some((id_relative_to, x, y));
+		}
+
+		{
+			let a = self.lookup(id);
+			let b = self.lookup(id_relative_to);
+			assert_eq!((a.0 - b.0, a.1 - b.1), (x, y));
+		}
+	}
+	// }}}
+	// {{{ Margins
+	#[inline]
+	pub fn margin(&mut self, id: LayoutBoxId, t: i32, r: i32, b: i32, l: i32) -> LayoutBoxId {
+		let inner = self.boxes[id.0];
+		let out = self.make_box(
+			(inner.width as i32 + l + r) as u32,
+			(inner.height as i32 + t + b) as u32,
+		);
+		self.edit_to_relative(id, out, l, t);
+		out
+	}
+
+	#[inline]
+	pub fn margin_xy(&mut self, inner: LayoutBoxId, x: i32, y: i32) -> LayoutBoxId {
+		self.margin(inner, y, x, y, x)
+	}
+
+	#[inline]
+	pub fn margin_uniform(&mut self, inner: LayoutBoxId, amount: i32) -> LayoutBoxId {
+		self.margin(inner, amount, amount, amount, amount)
+	}
+	// }}}
+	// {{{ Glueing
+	#[inline]
+	pub fn glue_horizontally(
+		&mut self,
+		first_id: LayoutBoxId,
+		second_id: LayoutBoxId,
+	) -> LayoutBoxId {
+		let first = self.boxes[first_id.0];
+		let second = self.boxes[second_id.0];
+		let id = self.make_box(first.width.max(second.width), first.height + second.height);
+
+		self.edit_to_relative(first_id, id, 0, 0);
+		self.edit_to_relative(second_id, id, 0, first.height as i32);
+		id
+	}
+
+	#[inline]
+	pub fn glue_vertically(
+		&mut self,
+		first_id: LayoutBoxId,
+		second_id: LayoutBoxId,
+	) -> LayoutBoxId {
+		let first = self.boxes[first_id.0];
+		let second = self.boxes[second_id.0];
+		let id = self.make_box(first.width + second.width, first.height.max(second.height));
+
+		self.edit_to_relative(first_id, id, 0, 0);
+		self.edit_to_relative(second_id, id, first.width as i32, 0);
+		id
+	}
+	// }}}
+	// {{{ Repeating
+	pub fn repeated_evenly(
+		&mut self,
+		id: LayoutBoxId,
+		amount: (u32, u32),
+	) -> (LayoutBoxId, impl Iterator<Item = (i32, i32)>) {
+		let inner = self.boxes[id.0];
+		let outer_id = self.make_box(inner.width * amount.0, inner.height * amount.1);
+		self.edit_to_relative(id, outer_id, 0, 0);
+
+		(
+			outer_id,
+			(0..amount.0 * amount.1).into_iter().map(move |i| {
+				let (y, x) = i.div_rem_euclid(&amount.0);
+				((x * inner.width) as i32, (y * inner.height) as i32)
+			}),
+		)
+	}
+	// }}}
+	// {{{ Lookup box
+	pub fn lookup(&self, id: LayoutBoxId) -> (i32, i32, u32, u32) {
+		let current = self.boxes[id.0];
+		if let Some((to, dx, dy)) = current.relative_to {
+			let (x, y, _, _) = self.lookup(to);
+			(x + dx, y + dy, current.width, current.height)
+		} else {
+			(0, 0, current.width, current.height)
+		}
+	}
+
+	#[inline]
+	pub fn width(&self, id: LayoutBoxId) -> u32 {
+		self.boxes[id.0].width
+	}
+
+	#[inline]
+	pub fn height(&self, id: LayoutBoxId) -> u32 {
+		self.boxes[id.0].height
+	}
+
+	#[inline]
+	pub fn position_relative_to(&self, id: LayoutBoxId, pos: (i32, i32)) -> (i32, i32) {
+		let current = self.lookup(id);
+		((pos.0 as i32 + current.0), (pos.1 as i32 + current.1))
+	}
+	// }}}
+}
+
+impl LayoutDrawer {
+	pub fn new(layout: LayoutManager, canvas: BitmapCanvas) -> Self {
+		Self { layout, canvas }
+	}
+
+	// {{{ Drawing
+	// {{{ Draw pixel
+	pub fn set_pixel(&mut self, id: LayoutBoxId, pos: (u32, u32), color: (u8, u8, u8, u8)) {
+		let pos = self
+			.layout
+			.position_relative_to(id, (pos.0 as i32, pos.1 as i32));
+		self.canvas.set_pixel((pos.0 as u32, pos.1 as u32), color);
+	}
+	// }}}
+	// {{{ Draw RGB image
+	/// Draws a bitmap image
+	pub fn blit_rbg(&mut self, id: LayoutBoxId, pos: (i32, i32), dims: (u32, u32), src: &[u8]) {
+		let pos = self.layout.position_relative_to(id, pos);
+		self.canvas.blit_rbg(pos, dims, src);
+	}
+	// }}}
+	// {{{ Draw RGBA image
+	/// Draws a bitmap image taking care of the alpha channel.
+	pub fn blit_rbga(&mut self, id: LayoutBoxId, pos: (i32, i32), dims: (u32, u32), src: &[u8]) {
+		let pos = self.layout.position_relative_to(id, pos);
+		self.canvas.blit_rbga(pos, dims, src);
+	}
+	// }}}
+	// {{{ Fill
+	/// Fills with solid color
+	pub fn fill(&mut self, id: LayoutBoxId, color: (u8, u8, u8, u8)) {
+		let current = self.layout.lookup(id);
+		self.canvas
+			.fill((current.0, current.1), (current.2, current.3), color);
+	}
+	// }}}
+	// {{{ Draw text
+	/// Render text
+	pub fn text(
+		&mut self,
+		id: LayoutBoxId,
+		pos: (i32, i32),
+		face: Face,
+		size: u32,
+		text: &str,
+		color: (u8, u8, u8, u8),
+	) -> Result<(), Error> {
+		let pos = self.layout.position_relative_to(id, pos);
+		self.canvas.text(pos, face, size, text, color)
+	}
+	// }}}
+	// }}}
+}
+// }}}
diff --git a/src/chart.rs b/src/chart.rs
index 2442121..3d21f5b 100644
--- a/src/chart.rs
+++ b/src/chart.rs
@@ -1,7 +1,8 @@
 use std::path::PathBuf;
 
+use image::{ImageBuffer, Rgb};
 use serde::{Deserialize, Serialize};
-use sqlx::{prelude::FromRow, SqlitePool};
+use sqlx::SqlitePool;
 
 use crate::context::Error;
 
@@ -78,7 +79,7 @@ impl TryFrom<String> for Side {
 }
 // }}}
 // {{{ Song
-#[derive(Debug, Clone, FromRow)]
+#[derive(Debug, Clone)]
 pub struct Song {
 	pub id: u32,
 	pub title: String,
@@ -91,7 +92,13 @@ pub struct Song {
 }
 // }}}
 // {{{ Chart
-#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
+#[derive(Debug, Clone, Copy)]
+pub struct Jacket {
+	pub raw: &'static [u8],
+	pub bitmap: &'static ImageBuffer<Rgb<u8>, Vec<u8>>,
+}
+
+#[derive(Debug, Clone)]
 pub struct Chart {
 	pub id: u32,
 	pub song_id: u32,
@@ -104,7 +111,7 @@ pub struct Chart {
 	pub note_count: u32,
 	pub chart_constant: u32,
 
-	pub cached_jacket: Option<&'static [u8]>,
+	pub cached_jacket: Option<Jacket>,
 }
 
 impl Chart {
diff --git a/src/commands/chart.rs b/src/commands/chart.rs
index 989df40..d4ca796 100644
--- a/src/commands/chart.rs
+++ b/src/commands/chart.rs
@@ -19,8 +19,8 @@ pub async fn chart(
 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
 
 	let attachement_name = "chart.png";
-	let icon_attachement = match chart.cached_jacket {
-		Some(bytes) => Some(CreateAttachment::bytes(bytes, attachement_name)),
+	let icon_attachement = match chart.cached_jacket.as_ref() {
+		Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, attachement_name)),
 		None => None,
 	};
 
diff --git a/src/commands/stats.rs b/src/commands/stats.rs
index 1da3d34..9a8dc21 100644
--- a/src/commands/stats.rs
+++ b/src/commands/stats.rs
@@ -17,8 +17,11 @@ use poise::{
 use sqlx::query_as;
 
 use crate::{
+	bitmap::{BitmapCanvas, LayoutDrawer, LayoutManager},
+	chart::{Chart, Song},
 	context::{Context, Error},
-	score::{guess_song_and_chart, DbPlay, Score},
+	jacket::BITMAP_IMAGE_SIZE,
+	score::{guess_song_and_chart, DbPlay, Play, Score},
 	user::{discord_it_to_discord_user, User},
 };
 
@@ -27,7 +30,7 @@ use crate::{
 #[poise::command(
 	prefix_command,
 	slash_command,
-	subcommands("chart"),
+	subcommands("chart", "b30"),
 	subcommand_required
 )]
 pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> {
@@ -212,7 +215,6 @@ pub async fn plot(
 				.iter()
 				.map(|(t, s)| Circle::new((*t, *s), 3, BLUE.filled())),
 		)?;
-
 		root.present()?;
 	}
 
@@ -228,3 +230,226 @@ pub async fn plot(
 	Ok(())
 }
 // }}}
+// {{{ B30
+/// Show the 30 best scores
+#[poise::command(prefix_command, slash_command)]
+pub async fn b30(ctx: Context<'_>) -> Result<(), Error> {
+	let user = match User::from_context(&ctx).await {
+		Ok(user) => user,
+		Err(_) => {
+			ctx.say("You are not an user in my database, sorry!")
+				.await?;
+			return Ok(());
+		}
+	};
+
+	let plays: Vec<DbPlay> = query_as(
+		"
+        SELECT id, chart_id, user_id,
+        created_at, MAX(score) as score, zeta_score,
+        creation_ptt, creation_zeta_ptt, far_notes, max_recall, discord_attachment_id
+        FROM plays p
+        WHERE user_id = ?
+        GROUP BY chart_id
+        ORDER BY score DESC
+    ",
+	)
+	.bind(user.id)
+	.fetch_all(&ctx.data().db)
+	.await?;
+
+	if plays.len() < 30 {
+		ctx.reply("Not enough plays found").await?;
+		return Ok(());
+	}
+
+	// TODO: consider not reallocating everything here
+	let mut plays: Vec<(Play, &Song, &Chart)> = plays
+		.into_iter()
+		.map(|play| {
+			let play = play.to_play();
+			// TODO: change the .lookup to perform binary search or something
+			let (song, chart) = ctx.data().song_cache.lookup_chart(play.chart_id)?;
+			Ok((play, song, chart))
+		})
+		.collect::<Result<Vec<_>, Error>>()?;
+
+	plays.sort_by_key(|(play, _, chart)| -play.score.play_rating(chart.chart_constant));
+	plays.truncate(30);
+
+	let mut layout = LayoutManager::default();
+	let jacket_area = layout.make_box(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE);
+	let jacket_margin = 10;
+	let jacket_with_margin =
+		layout.margin(jacket_area, jacket_margin, jacket_margin, 5, jacket_margin);
+	let top_left_area = layout.make_box(90, layout.height(jacket_with_margin));
+	let top_area = layout.glue_vertically(top_left_area, jacket_with_margin);
+	let bottom_area = layout.make_box(layout.width(top_area), 40);
+	let item_area = layout.glue_horizontally(top_area, bottom_area);
+	let item_with_margin = layout.margin_xy(item_area, 25, 20);
+	let (item_grid, item_origins) = layout.repeated_evenly(item_with_margin, (5, 6));
+	let root = item_grid;
+
+	// layout.normalize(root);
+	let width = layout.width(root);
+	let height = layout.height(root);
+
+	let canvas = BitmapCanvas::new(width, height);
+	let mut drawer = LayoutDrawer::new(layout, canvas);
+
+	let asset_cache = &ctx.data().jacket_cache;
+	let bg = &asset_cache.b30_background;
+
+	drawer.blit_rbg(
+		root,
+		(
+			-((bg.width() - width) as i32) / 2,
+			-((bg.height() - height) as i32) / 2,
+		),
+		bg.dimensions(),
+		bg.as_raw(),
+	);
+
+	for (i, origin) in item_origins.enumerate() {
+		drawer
+			.layout
+			.edit_to_relative(item_with_margin, item_grid, origin.0, origin.1);
+
+		drawer.fill(top_area, (59, 78, 102, 255));
+
+		let (_play, song, chart) = &plays[i];
+
+		// {{{ Display jacket
+		let jacket = chart.cached_jacket.as_ref().ok_or_else(|| {
+			format!(
+				"Cannot find jacket for chart {} [{:?}]",
+				song.title, chart.difficulty
+			)
+		})?;
+
+		drawer.blit_rbg(
+			jacket_area,
+			(0, 0),
+			jacket.bitmap.dimensions(),
+			&jacket.bitmap.as_raw(),
+		);
+		// }}}
+		// {{{ Display difficulty background
+		let diff_bg = &asset_cache.diff_backgrounds[chart.difficulty.to_index()];
+		drawer.blit_rbga(
+			jacket_area,
+			(
+				BITMAP_IMAGE_SIZE as i32 - (diff_bg.width() as i32) / 2,
+				-(diff_bg.height() as i32) / 2,
+			),
+			diff_bg.dimensions(),
+			&diff_bg.as_raw(),
+		);
+		// }}}
+		// {{{ Display difficulty text
+		let x_offset = if chart.level.ends_with("+") {
+			3
+		} else if chart.level == "11" {
+			-2
+		} else {
+			0
+		};
+		// jacket_area.draw_text(
+		// 	&chart.level,
+		// 	&TextStyle::from(("Exo", 30).into_font())
+		// 		.color(&WHITE)
+		// 		.with_anchor::<RGBAColor>(Pos {
+		// 			h_pos: HPos::Center,
+		// 			v_pos: VPos::Center,
+		// 		})
+		// 		.into_text_style(&jacket_area),
+		// 	(BITMAP_IMAGE_SIZE as i32 + x_offset, 2),
+		// )?;
+		// }}}
+		// {{{ Display chart name
+		// Draw background
+		drawer.fill(bottom_area, (0x82, 0x71, 0xA7, 255));
+
+		let tx = 10;
+		let ty = drawer.layout.height(bottom_area) as i32 / 2;
+
+		// let text = &song.title;
+		// let mut size = 30;
+		// let mut text_style = TextStyle::from(("Exo", size).into_font().style(FontStyle::Bold))
+		// 	.with_anchor::<RGBAColor>(Pos {
+		// 		h_pos: HPos::Left,
+		// 		v_pos: VPos::Center,
+		// 	})
+		// 	.into_text_style(&bottom_area);
+		//
+		// while text_style.font.layout_box(text).unwrap().1 .0 >= item_area.0 as i32 - 20 {
+		// 	size -= 3;
+		// 	text_style.font = ("Exo", size).into_font();
+		// }
+		//
+		// Draw drop shadow
+		// bottom_area.draw_text(
+		// 	&song.title,
+		// 	&text_style.color(&RGBAColor(0, 0, 0, 0.2)),
+		// 	(tx + 3, ty + 3),
+		// )?;
+		// bottom_area.draw_text(
+		// 	&song.title,
+		// 	&text_style.color(&RGBAColor(0, 0, 0, 0.2)),
+		// 	(tx - 3, ty + 3),
+		// )?;
+		// bottom_area.draw_text(
+		// 	&song.title,
+		// 	&text_style.color(&RGBAColor(0, 0, 0, 0.2)),
+		// 	(tx + 3, ty - 3),
+		// )?;
+		// bottom_area.draw_text(
+		// 	&song.title,
+		// 	&text_style.color(&RGBAColor(0, 0, 0, 0.2)),
+		// 	(tx - 3, ty - 3),
+		// )?;
+
+		// Draw text
+		// bottom_area.draw_text(&song.title, &text_style.color(&WHITE), (tx, ty))?;
+		// }}}
+		// {{{ Display index
+		let bg = &asset_cache.count_background;
+
+		// Draw background
+		drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg.dimensions(), bg);
+
+		// let text_style = TextStyle::from(("Exo", 30).into_font().style(FontStyle::Bold))
+		// 	.with_anchor::<RGBAColor>(Pos {
+		// 		h_pos: HPos::Left,
+		// 		v_pos: VPos::Center,
+		// 	})
+		// 	.into_text_style(&area);
+
+		let tx = 7;
+		let ty = (jacket_margin + bg.height() as i32 / 2) - 3;
+
+		// Draw drop shadow
+		// area.draw_text(
+		// 	&format!("#{}", i + 1),
+		// 	&text_style.color(&BLACK),
+		// 	(tx + 2, ty + 2),
+		// )?;
+
+		// Draw main text
+		// area.draw_text(&format!("#{}", i + 1), &text_style.color(&WHITE), (tx, ty))?;
+		// }}}
+	}
+
+	let mut out_buffer = Vec::new();
+	let image: ImageBuffer<Rgb<u8>, _> =
+		ImageBuffer::from_raw(width, height, drawer.canvas.buffer).unwrap();
+
+	let mut cursor = Cursor::new(&mut out_buffer);
+	image.write_to(&mut cursor, image::ImageFormat::Png)?;
+
+	let reply = CreateReply::default().attachment(CreateAttachment::bytes(out_buffer, "b30.png"));
+	ctx.send(reply).await?;
+
+	Ok(())
+}
+// }}}
diff --git a/src/jacket.rs b/src/jacket.rs
index d6c26e7..4267bd2 100644
--- a/src/jacket.rs
+++ b/src/jacket.rs
@@ -1,13 +1,13 @@
 use std::{fs, path::PathBuf, str::FromStr};
 
-use image::{GenericImageView, Rgba};
+use freetype::{Face, Library};
+use image::{imageops::FilterType, GenericImageView, ImageBuffer, Rgb, Rgba};
 use kd_tree::{KdMap, KdPoint};
 use num::Integer;
-use serde::{Deserialize, Serialize};
-use serde_with::serde_as;
 
 use crate::{
-	chart::{Difficulty, SongCache},
+	bitmap::BitmapCanvas,
+	chart::{Difficulty, Jacket, SongCache},
 	context::Error,
 	score::guess_chart_name,
 };
@@ -15,11 +15,10 @@ use crate::{
 /// How many sub-segments to split each side into
 pub const SPLIT_FACTOR: u32 = 8;
 pub const IMAGE_VEC_DIM: usize = (SPLIT_FACTOR * SPLIT_FACTOR * 3) as usize;
+pub const BITMAP_IMAGE_SIZE: u32 = 192;
 
-#[serde_as]
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone)]
 pub struct ImageVec {
-	#[serde_as(as = "[_; IMAGE_VEC_DIM]")]
 	pub colors: [f32; IMAGE_VEC_DIM],
 }
 
@@ -44,16 +43,16 @@ impl ImageVec {
 			let mut count = 0;
 
 			for (_, _, pixel) in cropped.pixels() {
-				r += pixel.0[0] as u64;
-				g += pixel.0[1] as u64;
-				b += pixel.0[2] as u64;
+				r += (pixel.0[0] as u64).pow(2);
+				g += (pixel.0[1] as u64).pow(2);
+				b += (pixel.0[2] as u64).pow(2);
 				count += 1;
 			}
 
 			let count = count as f64;
-			let r = r as f64 / count;
-			let g = g as f64 / count;
-			let b = b as f64 / count;
+			let r = (r as f64 / count).sqrt();
+			let g = (g as f64 / count).sqrt();
+			let b = (b as f64 / count).sqrt();
 			colors[i as usize * 3 + 0] = r as f32;
 			colors[i as usize * 3 + 1] = g as f32;
 			colors[i as usize * 3 + 2] = b as f32;
@@ -77,9 +76,11 @@ impl KdPoint for ImageVec {
 	}
 }
 
-#[derive(Serialize, Deserialize)]
 pub struct JacketCache {
 	tree: KdMap<ImageVec, u32>,
+	pub b30_background: ImageBuffer<Rgb<u8>, Vec<u8>>,
+	pub count_background: ImageBuffer<Rgba<u8>, Vec<u8>>,
+	pub diff_backgrounds: [ImageBuffer<Rgba<u8>, Vec<u8>>; 5],
 }
 
 impl JacketCache {
@@ -96,7 +97,7 @@ impl JacketCache {
 
 		let mut jackets = Vec::new();
 		let entries = fs::read_dir(data_dir.join("songs")).expect("Couldn't read songs directory");
-		for entry in entries {
+		for (i, entry) in entries.enumerate() {
 			let dir = entry?;
 			let raw_dir_name = dir.file_name();
 			let dir_name = raw_dir_name.to_str().unwrap();
@@ -127,6 +128,11 @@ impl JacketCache {
 				jackets.push((file.path(), song.id));
 
 				let contents = fs::read(file.path())?.leak();
+				let bitmap = Box::leak(Box::new(
+					image::load_from_memory(contents)?
+						.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Nearest)
+						.into_rgb8(),
+				));
 
 				if name == "base" {
 					let item = song_cache.lookup_mut(song.id).unwrap();
@@ -156,14 +162,20 @@ impl JacketCache {
 						if !specialized_path.exists() && !dest.exists() {
 							std::os::unix::fs::symlink(file.path(), dest)
 								.expect("Could not symlink jacket");
-							chart.cached_jacket = Some(contents);
+							chart.cached_jacket = Some(Jacket {
+								raw: contents,
+								bitmap,
+							});
 						}
 					}
 				} else if difficulty.is_some() {
 					std::os::unix::fs::symlink(file.path(), chart.jacket_path(data_dir))
 						.expect("Could not symlink jacket");
 					let chart = song_cache.lookup_chart_mut(chart.id).unwrap();
-					chart.cached_jacket = Some(contents);
+					chart.cached_jacket = Some(Jacket {
+						raw: contents,
+						bitmap,
+					});
 				}
 			}
 		}
@@ -180,8 +192,36 @@ impl JacketCache {
 			}
 		}
 
+		let assets_dir = data_dir.join("assets");
+
+		let lib = Library::init()?;
+		let saira_font = lib.new_face(assets_dir.join("saira-variable.ttf"), 0)?;
+		let mut canvas = BitmapCanvas::new(0, 0);
+		canvas.text(
+			(0, 0),
+			saira_font,
+			20,
+			"Yo, this is a test!",
+			(0, 0, 0, 0xff),
+		)?;
+
 		let result = Self {
 			tree: KdMap::build_by_ordered_float(entries),
+			b30_background: image::open(assets_dir.join("b30_background.jpg"))?
+				.resize(2048 * 2, 1535 * 2, FilterType::Nearest)
+				.blur(20.0)
+				.into_rgb8(),
+			count_background: image::open(assets_dir.join("count_background.png"))?
+				.blur(1.0)
+				.into_rgba8(),
+			diff_backgrounds: Difficulty::DIFFICULTY_SHORTHANDS.try_map(
+				|shorthand| -> Result<_, Error> {
+					Ok(image::open(
+						assets_dir.join(format!("diff-{}.png", shorthand.to_lowercase())),
+					)?
+					.into_rgba8())
+				},
+			)?,
 		};
 
 		Ok(result)
diff --git a/src/main.rs b/src/main.rs
index de9d412..8b88cc5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,10 @@
 #![warn(clippy::str_to_string)]
 #![feature(iter_map_windows)]
 #![feature(let_chains)]
+#![feature(array_try_map)]
 #![feature(async_closure)]
 
+mod bitmap;
 mod chart;
 mod commands;
 mod context;
diff --git a/src/score.rs b/src/score.rs
index 127d709..11bc09e 100644
--- a/src/score.rs
+++ b/src/score.rs
@@ -153,8 +153,16 @@ impl Score {
 
 			// Compute score from note breakdown subpairs
 			let pf_score = Score::compute_naive(note_count, pures, fars);
-			let fl_score = Score::compute_naive(note_count, note_count - losts - fars, fars);
-			let lp_score = Score::compute_naive(note_count, pures, note_count - losts - pures);
+			let fl_score = Score::compute_naive(
+				note_count,
+				note_count.checked_sub(losts + fars).unwrap_or(0),
+				fars,
+			);
+			let lp_score = Score::compute_naive(
+				note_count,
+				pures,
+				note_count.checked_sub(losts + pures).unwrap_or(0),
+			);
 
 			if no_shiny_scores.len() == 1 {
 				// {{{ Score is fixed, gotta figure out the exact distribution
@@ -450,14 +458,14 @@ impl Play {
 	pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> {
 		if let Some(fars) = self.far_notes {
 			let (_, shinies, units) = self.score.analyse(note_count);
-			let (pures, rem) = (units - fars).div_rem_euclid(&2);
+			let (pures, rem) = units.checked_sub(fars)?.div_rem_euclid(&2);
 			if rem == 1 {
 				println!("The impossible happened: got an invalid amount of far notes!");
 				return None;
 			}
 
-			let lost = note_count - fars - pures;
-			let non_max_pures = pures - shinies;
+			let lost = note_count.checked_sub(fars + pures)?;
+			let non_max_pures = pures.checked_sub(shinies)?;
 			Some((shinies, non_max_pures, fars, lost))
 		} else {
 			None
@@ -474,7 +482,7 @@ impl Play {
 				return None;
 			}
 
-			let non_max_pures = chart.note_count + 10_000_000 - score;
+			let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?;
 			if non_max_pures == 0 {
 				Some("MPM".to_string())
 			} else {
@@ -507,8 +515,8 @@ impl Play {
 		author: Option<&poise::serenity_prelude::User>,
 	) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> {
 		let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index);
-		let icon_attachement = match chart.cached_jacket {
-			Some(bytes) => Some(CreateAttachment::bytes(bytes, &attachement_name)),
+		let icon_attachement = match chart.cached_jacket.as_ref() {
+			Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)),
 			None => None,
 		};
 
@@ -527,16 +535,16 @@ impl Play {
 				true,
 			)
 			.field("Grade", self.score.grade(), true)
-			.field("ζ-Score", format!("{} (+?)", self.zeta_score), true)
+			.field("ξ-Score", format!("{} (+?)", self.zeta_score), true)
 			.field(
-				"ζ-Rating",
+				"ξ-Rating",
 				format!(
 					"{:.2} (+?)",
 					(self.zeta_score.play_rating(chart.chart_constant)) as f32 / 100.
 				),
 				true,
 			)
-			.field("ζ-Grade", self.zeta_score.grade(), true)
+			.field("ξ-Grade", self.zeta_score.grade(), true)
 			.field(
 				"Status",
 				self.status(chart).unwrap_or("?".to_string()),