diff --git a/game_core/src/const_data/mod.rs b/game_core/src/const_data/mod.rs
index 94d05aff269bf82edc98981a196fbcd1178a8de7..b68881b5ffcbf57919862b1a2193015ffdafe6cb 100644
--- a/game_core/src/const_data/mod.rs
+++ b/game_core/src/const_data/mod.rs
@@ -4,12 +4,23 @@
 use std::collections::HashMap;
 
 use lazy_static::lazy_static;
+use serde::{Deserialize, Serialize};
 
-use crate::world::TradeGood;
+use crate::world::{CraftSpecialism, TradeGood};
+
+#[derive(Serialize, Deserialize)]
+struct RawSpecialisms {
+	specialism: Vec<CraftSpecialism>,
+}
 
 lazy_static! {
 	pub static ref TRADE_GOODS: HashMap<String, TradeGood> =
 		toml::from_slice(include_bytes!("../../../raw_assets/trade_goods.toml")).unwrap();
+	pub static ref SPECIALISMS: Vec<CraftSpecialism> = {
+		let initial: RawSpecialisms =
+			toml::from_slice(include_bytes!("../../../raw_assets/specialisms.toml")).unwrap();
+		initial.specialism
+	};
 }
 
 /// Convert the name of a trade good into its representation.
diff --git a/game_core/src/ui/sync/sync_trade.rs b/game_core/src/ui/sync/sync_trade.rs
index 494286aca51a68b93dc4f2a58060b27b37beb5a5..7cd58dd4a043a01913f99f0adad2d86118cb27e1 100644
--- a/game_core/src/ui/sync/sync_trade.rs
+++ b/game_core/src/ui/sync/sync_trade.rs
@@ -6,7 +6,7 @@ use bevy::prelude::{Entity, Query, Res, ResMut, Resource, With};
 use crate::const_data::{get_goods_from_name, get_goods_from_name_checked, TRADE_GOODS};
 use crate::states::Player;
 use crate::world::{
-	CurrentResidence, TownName, TradeGood, TradeManifest, TradingState, TravelTarget,
+	CurrentResidence, HungerState, TownName, TradeGood, TradeManifest, TradingState, TravelTarget,
 };
 
 #[derive(Clone)]
@@ -24,6 +24,7 @@ pub struct UITradeData {
 	pub player_gold: usize,
 	pub player_entity: Option<Entity>,
 	pub shop_items: Vec<ItemTradeData>,
+	pub hunger_descriptor: String,
 }
 
 pub fn sync_ui_trade_data(
@@ -36,7 +37,13 @@ pub fn sync_ui_trade_data(
 		),
 		With<Player>,
 	>,
-	town_query: Query<(Entity, &TownName, &TradingState, &Handle<TradeManifest>)>,
+	town_query: Query<(
+		Entity,
+		&TownName,
+		&TradingState,
+		&HungerState,
+		&Handle<TradeManifest>,
+	)>,
 	manifests: Res<Assets<TradeManifest>>,
 	mut trade_data: ResMut<UITradeData>,
 ) {
@@ -57,9 +64,9 @@ pub fn sync_ui_trade_data(
 			CurrentResidence::TravellingFrom(..) => String::new(),
 		});
 
-	let (town_entity, _, town_trading_state, manifest_handle) = match town_query
+	let (town_entity, _, town_trading_state, hunger_state, manifest_handle) = match town_query
 		.into_iter()
-		.find(|(_, name, _, _)| town_name == *name.0)
+		.find(|(_, name, _, _, _)| town_name == *name.0)
 	{
 		Some(v) => v,
 		None => {
@@ -71,6 +78,7 @@ pub fn sync_ui_trade_data(
 
 	trade_data.town_entity = Some(town_entity);
 	trade_data.town_gold = town_trading_state.gold.max(0) as usize;
+	trade_data.hunger_descriptor = hunger_state.get_town_descriptor();
 
 	for (item_name, trade_good) in TRADE_GOODS.iter() {
 		let manifest = match manifests.get(manifest_handle) {
diff --git a/game_core/src/ui/utilities.rs b/game_core/src/ui/utilities.rs
index f48ce1c6a1c33dd690117db835e560c856faff3d..41e6f28e6232df1306bb162c1eab44aedd21e873 100644
--- a/game_core/src/ui/utilities.rs
+++ b/game_core/src/ui/utilities.rs
@@ -249,6 +249,13 @@ pub mod context {
 				UITradeData,
 				render_shop_panel
 			);
+			register_widget_with_resource!(
+				widget_context,
+				TavernPanelProps,
+				EmptyState,
+				UITradeData,
+				render_tavern_panel
+			);
 			register_widget_with_resource!(
 				widget_context,
 				StatsPanelProps,
diff --git a/game_core/src/ui/widgets/mod.rs b/game_core/src/ui/widgets/mod.rs
index 8c6515b3cb7d35a48711799e7d8acfea8ae525aa..d4bd4a662d8ed43fdf91e4123a1ad6df97c27700 100644
--- a/game_core/src/ui/widgets/mod.rs
+++ b/game_core/src/ui/widgets/mod.rs
@@ -1,12 +1,14 @@
 mod encounter_panel;
 mod shop_panel;
 mod stats_panel;
+mod tavern_panel;
 mod town_menu;
 mod transit_panel;
 
 pub use encounter_panel::{render_encounter_panel, EncounterPanel, EncounterPanelProps};
 pub use shop_panel::{render_shop_panel, ShopPanel, ShopPanelProps};
 pub use stats_panel::{render_stats_panel, StatsPanel, StatsPanelProps};
+pub use tavern_panel::{render_tavern_panel, TavernPanel, TavernPanelProps};
 pub use town_menu::{
 	render_town_menu_panel, TownMenuPanel, TownMenuPanelProps, TownMenuPanelState,
 };
diff --git a/game_core/src/ui/widgets/tavern_panel.rs b/game_core/src/ui/widgets/tavern_panel.rs
new file mode 100644
index 0000000000000000000000000000000000000000..dbd1f45865757314538afe30ee250226e994bee5
--- /dev/null
+++ b/game_core/src/ui/widgets/tavern_panel.rs
@@ -0,0 +1,69 @@
+use bevy::prelude::*;
+use kayak_ui::prelude::*;
+use kayak_ui::widgets::{
+	ElementBundle, ScrollBoxBundle, ScrollBoxProps, ScrollContextProviderBundle, TextProps,
+	TextWidgetBundle,
+};
+
+use crate::assets::AssetHandles;
+use crate::states::Player;
+use crate::system::utilities::format_ui_distance;
+use crate::ui::components::*;
+use crate::ui::prelude::*;
+use crate::ui::sync::{UITradeData, UITravelInfo};
+use crate::ui::widgets::*;
+use crate::world::{CurrentResidence, MapQuery, TownPaths};
+use crate::{basic_widget, empty_props, on_button_click};
+
+empty_props!(TavernPanelProps);
+basic_widget!(TavernPanelProps => TavernPanel);
+
+pub fn render_tavern_panel(
+	In((widget_context, entity)): In<(KayakWidgetContext, Entity)>,
+	mut commands: Commands,
+	ui_data: Res<UITradeData>,
+	query: Query<&TavernPanelProps>,
+) -> bool {
+	let parent_id = Some(entity);
+
+	if let Ok(props) = query.get(entity) {
+		rsx! {
+			<ElementBundle>
+				<TextWidgetBundle
+					styles={KStyle {
+						left: stretch(1.0),
+						right: stretch(1.0),
+						color: value(Color::BLACK),
+						..Default::default()
+					}}
+					text={TextProps {
+						size: 32.0,
+						content: String::from("A strange bartender eyes you suspiciously"),
+						..Default::default()
+					}}
+				/>
+				<ScrollContextProviderBundle>
+					<ScrollBoxBundle>
+						<TextWidgetBundle
+							styles={KStyle {
+								top: px(20.0),
+								bottom: px(20.0),
+								left: stretch(1.0),
+								right: stretch(1.0),
+								color: value(Color::BLACK),
+								..Default::default()
+							}}
+							text={TextProps {
+								size: 28.0,
+								content: ui_data.hunger_descriptor.clone(),
+								..Default::default()
+							}}
+						/>
+					</ScrollBoxBundle>
+				</ScrollContextProviderBundle>
+			</ElementBundle>
+		};
+	}
+
+	true
+}
diff --git a/game_core/src/ui/widgets/town_menu.rs b/game_core/src/ui/widgets/town_menu.rs
index af5b3072aae5677978d1843d802edb4066c5daba..a95d9afcfdb74f0ad334f22058a9989f62277db0 100644
--- a/game_core/src/ui/widgets/town_menu.rs
+++ b/game_core/src/ui/widgets/town_menu.rs
@@ -171,19 +171,7 @@ pub fn render_town_menu_panel(
 							}
 							TownMenuTab::Tavern => {
 								constructor! {
-									<TextWidgetBundle
-										styles={KStyle {
-											left: stretch(1.0),
-											right: stretch(1.0),
-											color: value(Color::BLACK),
-											..Default::default()
-										}}
-										text={TextProps {
-											size: 32.0,
-											content: String::from("A strange bartender eyes you suspiciously"),
-											..Default::default()
-										}}
-									/>
+									<TavernPanel />
 								}
 							}
 							TownMenuTab::None => {}
diff --git a/game_core/src/world/hunger.rs b/game_core/src/world/hunger.rs
index 005047e6c468b708e84e2d9d450e3e9d2a31b361..34d42e3856f5e9abe27bb94bccc26dc93ff96e2e 100644
--- a/game_core/src/world/hunger.rs
+++ b/game_core/src/world/hunger.rs
@@ -27,6 +27,23 @@ impl HungerState {
 			starvation_ticks: 0.0,
 		}
 	}
+	pub fn get_town_descriptor(&self) -> String {
+		let percent = self.sustenance as f32 / TOWN_FOOD_TARGET as f32;
+
+		if percent > 1.0 {
+			String::from("The townsfolk have never looked healthier. They are bustling and joyous, brimming with energy. You'll have one of whatever they've been having!")
+		} else if percent > 0.75 {
+			String::from("The atmosphere is generally lively and upbeat, but as you glance around you notice a few worried faces.")
+		} else if percent > 0.55 {
+			String::from("You find yourself unable to strike up a conversation with any of the local townsfolk. There's a certain desperation about the people you see, but you can't quite place why. In the background, you do not hear the usual clinking of crockery and cups.")
+		} else if percent > 0.15 {
+			String::from("The tavern is looking quite empty, with only a few elderly or especially scruffy patrons within sight. None have a beer or food in front of them; instead they count the rings in the wooden tables.")
+		} else if percent > 0.001 {
+			String::from("Every wretched soul whose path you have crossed to reach the tavern has had the same hungry look in their eyes. The atmosphere is tense, as if at any moment the whole town could erupt into chaos. You decide to make your stay short.")
+		} else {
+			String::from("You look around tavern and see only the emaciated corpses of the former townsfolk. Checking one pile of corpses, you notice some covered in several bite marks. Are those the marks of human teeth? You dare not think too much about it.")
+		}
+	}
 }
 
 pub struct StarvationMarker;
diff --git a/game_core/src/world/mod.rs b/game_core/src/world/mod.rs
index b80e443139ec4ccc28baa992580380106f3b0286..7d2d877ebd7218c1b61825029a8ca1b991b53e28 100644
--- a/game_core/src/world/mod.rs
+++ b/game_core/src/world/mod.rs
@@ -9,6 +9,7 @@ mod encounters;
 mod generators;
 mod hunger;
 mod spawning;
+mod specialism;
 mod towns;
 mod trading;
 mod travel;
@@ -16,8 +17,9 @@ mod utils;
 mod world_query;
 
 pub use encounters::{EncounterState, WorldZones};
-pub use hunger::HungerState;
+pub use hunger::{HungerState, StarvationMarker};
 pub use spawning::PendingLoadState;
+pub use specialism::CraftSpecialism;
 pub use towns::{CurrentResidence, PathingResult, TownName, TownPaths, TravelPath, TravelTarget};
 pub use trading::{
 	ItemName, RosterEntry, TradeGood, TradeManifest, TradeManifestTickState, TradeRoster,
@@ -72,6 +74,7 @@ impl Plugin for WorldPlugin {
 				ConditionSet::new()
 					.run_in_state(AppState::InGame)
 					.with_system(trading::tick_manifests)
+					.with_system(specialism::check_specialisms)
 					.into(),
 			)
 			.add_system_set_to_stage(
diff --git a/game_core/src/world/spawning.rs b/game_core/src/world/spawning.rs
index daf6e6a901fd1dbd6034b2e522276c17abf0f2e2..016b38ba63c7382df467fe4c729342cf6d4b06e5 100644
--- a/game_core/src/world/spawning.rs
+++ b/game_core/src/world/spawning.rs
@@ -14,6 +14,7 @@ use num_traits::AsPrimitive;
 use serde_json::Value;
 
 use crate::assets::{AssetHandles, LdtkProject, LevelIndex, TilesetIndex};
+use crate::const_data::SPECIALISMS;
 use crate::persistance::PersistenceState;
 use crate::states::Player;
 use crate::system::camera::ChaseCam;
@@ -362,6 +363,8 @@ pub fn populate_world(
 				apply_skull_marker(&mut commands, &assets, ent);
 			});
 
+		log::info!("Spec {:#?}", *SPECIALISMS);
+
 		commands.insert_resource(trade_routes);
 		commands.insert_resource(world_zones);
 	}
diff --git a/game_core/src/world/specialism.rs b/game_core/src/world/specialism.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3ae6eadf727d12d54d9a9c24b0838c7bd197b4ee
--- /dev/null
+++ b/game_core/src/world/specialism.rs
@@ -0,0 +1,122 @@
+use std::fmt::format;
+
+use bevy::prelude::{Component, EventReader, Query};
+use bevy::utils::HashMap;
+use lazy_static::lazy_static;
+use serde::{Deserialize, Serialize};
+
+use crate::world::travel::WorldTickEvent;
+use crate::world::TradingState;
+
+#[derive(Clone, Debug, Default, Component, Serialize, Deserialize)]
+pub struct CraftSpecialism {
+	pub requirements: HashMap<String, usize>,
+	pub production: HashMap<String, usize>,
+}
+
+lazy_static! {
+	static ref REQUIREMENT_TEMPLATES: Vec<String> = {
+		let mut items = Vec::with_capacity(4);
+		items.push(String::from("I hear that the artisans at [[TOWN]] are pretty good at working with [[ITEMS]]. A wonder to behold!",));
+		items.push(String::from("I remember the last time I was at [[TOWN]]. I saw a craftsman making something with [[ITEMS]]. I hope I get to see that again one day...",));
+		items.push(String::from("If you happen to have [[ITEMS]], consider popping over to [[TOWN]]. I hear there's high demand there!",));
+		items.push(String::from("I've heard that there are a lot of folk able to make use of [[ITEMS]] over in [[TOWN]]."));
+		items
+	};
+	static ref PRODUCTION_TEMPLATES: Vec<String> = {
+		let mut items = Vec::with_capacity(3);
+		items.push(String::from("Have you ever seen the craftsfolk over in [[TOWN]] making [[ITEMS]]? It's a wonder to see. I heard they need a few bits and bobs to get going though."));
+		items.push(String::from("Got a hankering for [[ITEMS]]? If you can get some supplies to [[TOWN]], you might be in luck."));
+		items.push(String::from("My cousin over in [[TOWN]] is a dab hand at making [[ITEMS]]. Doesn't usually have the supplies for it, though."));
+		items
+	};
+}
+
+impl CraftSpecialism {
+	pub fn requirements_satisfied(&self, inventory: &TradingState) -> bool {
+		self.requirements.iter().all(|(name, amount)| {
+			inventory
+				.items
+				.get(name)
+				.map(|quantity| quantity >= amount)
+				.unwrap_or(false)
+		})
+	}
+
+	pub fn apply_to_inventory(&self, inventory: &mut TradingState) -> bool {
+		if self.requirements_satisfied(inventory) {
+			for (name, amount) in self.requirements.iter() {
+				inventory.remove_items(name, *amount);
+			}
+			for (name, amount) in self.production.iter() {
+				inventory.add_items(name, *amount);
+			}
+			true
+		} else {
+			false
+		}
+	}
+
+	fn format_item_map(map: &HashMap<String, usize>) -> String {
+		match map.len() {
+			0 => "...oh, I forget".to_string(),
+			1 => format!("an {}", map.keys().next().unwrap()),
+			2 => format!(
+				"{} and {}",
+				map.keys().nth(0).unwrap(),
+				map.keys().nth(1).unwrap()
+			),
+			_ => {
+				let first = fastrand::usize(0..map.len());
+				let mut second = fastrand::usize(0..map.len());
+				while second == first {
+					second = fastrand::usize(0..map.len());
+				}
+
+				format!(
+					"{}, {}, and...some other things, I think",
+					map.keys().nth(first).unwrap(),
+					map.keys().nth(second).unwrap()
+				)
+			}
+		}
+	}
+
+	pub fn format_requirements(&self) -> String {
+		Self::format_item_map(&self.requirements)
+	}
+
+	pub fn format_production(&self) -> String {
+		Self::format_item_map(&self.production)
+	}
+
+	pub fn format_rumour(&self, town_name: impl ToString) -> String {
+		let is_production = fastrand::bool();
+		if is_production {
+			let stub = &PRODUCTION_TEMPLATES[fastrand::usize(0..PRODUCTION_TEMPLATES.len())];
+			let output = stub
+				.replace("[[TOWN]]", town_name.to_string().as_str())
+				.replace("[[ITEMS]]", self.format_production().as_str());
+
+			output
+		} else {
+			let stub = &REQUIREMENT_TEMPLATES[fastrand::usize(0..REQUIREMENT_TEMPLATES.len())];
+			let output = stub
+				.replace("[[TOWN]]", town_name.to_string().as_str())
+				.replace("[[ITEMS]]", self.format_requirements().as_str());
+
+			output
+		}
+	}
+}
+
+pub fn check_specialisms(
+	mut query: Query<(&mut TradingState, &CraftSpecialism)>,
+	events: EventReader<WorldTickEvent>,
+) {
+	if !events.is_empty() {
+		for (mut trading_state, specialism) in &mut query {
+			specialism.apply_to_inventory(&mut trading_state);
+		}
+	}
+}
diff --git a/raw_assets/specialisms.toml b/raw_assets/specialisms.toml
new file mode 100644
index 0000000000000000000000000000000000000000..1b8c9023fb20e7b4ec446ca58c338cd428a07a47
--- /dev/null
+++ b/raw_assets/specialisms.toml
@@ -0,0 +1,38 @@
+[[specialism]]
+[specialism.requirements]
+Tomato = 3
+Cheese = 2
+Dough = 2
+[specialism.production]
+Pizza = 2
+
+[[specialism]]
+[specialism.requirements]
+Coal = 4
+"Iron Ore" = 3
+[specialism.production]
+"Iron Ingot" = 3
+
+[[specialism]]
+[specialism.requirements]
+Lumber = 10
+[specialism.production]
+"Spare Wheel" = 3
+
+[[specialism]]
+[specialism.requirements]
+Milk = 4
+[specialism.production]
+Cheese = 3
+
+[[specialism]]
+[specialism.requirements]
+Wheat = 4
+[specialism.production]
+Dough = 3
+
+[[specialism]]
+[specialism.requirements]
+Dough = 4
+[specialism.production]
+Bread = 3