diff --git a/validation/src/check.rs b/validation/src/check.rs
new file mode 100644
index 0000000..87cdcc6
--- /dev/null
+++ b/validation/src/check.rs
@@ -0,0 +1,315 @@
+use crate::download::download_asset_version;
+use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,MapInfo,ReadDomError};
+
+use heck::{ToSnakeCase,ToTitleCase};
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub enum Error{
+	ModelInfoDownload(rbx_asset::cloud::GetError),
+	CreatorTypeMustBeUser,
+	ParseUserID(core::num::ParseIntError),
+	ParseModelVersion(core::num::ParseIntError),
+	Download(crate::download::Error),
+	ModelFileDecode(ReadDomError),
+}
+impl std::fmt::Display for Error{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"{self:?}")
+	}
+}
+impl std::error::Error for Error{}
+
+#[allow(nonstandard_style)]
+pub struct CheckRequest{
+	pub ModelID:u64,
+}
+
+impl From<crate::nats_types::CheckMapfixRequest> for CheckRequest{
+	fn from(value:crate::nats_types::CheckMapfixRequest)->Self{
+		Self{
+			ModelID:value.ModelID,
+		}
+	}
+}
+impl From<crate::nats_types::CheckSubmissionRequest> for CheckRequest{
+	fn from(value:crate::nats_types::CheckSubmissionRequest)->Self{
+		Self{
+			ModelID:value.ModelID,
+		}
+	}
+}
+
+#[derive(Default)]
+pub struct CheckReport{
+	// === METADATA CHECKS ===
+	// the model must have exactly 1 root part (models uploaded to roblox can have multiple roots)
+	exactly_one_root:bool,
+	// the root must be of class Model
+	root_is_model:bool,
+	// the prefix of the model's name must match the game it was submitted for. bhop_ for bhop, and surf_ for surf
+	model_name_prefix_is_valid:bool,
+	// your model's name must match this regex: ^[a-z0-9_]
+	model_name_is_snake_case:bool,
+	// map must have a StringValue named Creator and DisplayName. additionally, they must both have a value
+	has_display_name:bool,
+	has_creator:bool,
+	// the display name must be capitalized
+	display_name_is_title_case:bool,
+	// you cannot have any Scripts or ModuleScripts that have the keyword 'getfenv" or 'require'
+	// you cannot have more than 50 duplicate scripts
+
+	// === MODE CHECKS ===
+	// Exactly one MapStart
+	exactly_one_mapstart:bool,
+	// At least one MapFinish
+	at_least_one_mapfinish:bool,
+	// Spawn0 or Spawn1 must exist
+	spawn1_exists:bool,
+	// No duplicate Spawn#
+	no_duplicate_spawns:bool,
+	// No duplicate WormholeOut# (duplicate WormholeIn# ok)
+	no_duplicate_wormhole_out:bool,
+}
+impl CheckReport{
+	pub fn pass(&self)->bool{
+		return self.exactly_one_root
+			 &&self.root_is_model
+			 &&self.model_name_prefix_is_valid
+			 &&self.model_name_is_snake_case
+			 &&self.has_display_name
+			 &&self.has_creator
+			 &&self.display_name_is_title_case
+			 &&self.exactly_one_mapstart
+			 &&self.at_least_one_mapfinish
+			 &&self.spawn1_exists
+			 &&self.no_duplicate_spawns
+			 &&self.no_duplicate_wormhole_out
+	}
+}
+impl std::fmt::Display for CheckReport{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,
+			"exactly_one_root={}\
+root_is_model={}\
+model_name_prefix_is_valid={}\
+model_name_is_snake_case={}\
+has_display_name={}\
+has_creator={}\
+display_name_is_title_case={}\
+exactly_one_mapstart={}\
+at_least_one_mapfinish={}\
+spawn1_exists={}\
+no_duplicate_spawns={}\
+no_duplicate_wormhole_out={}",
+			self.exactly_one_root,
+			self.root_is_model,
+			self.model_name_prefix_is_valid,
+			self.model_name_is_snake_case,
+			self.has_display_name,
+			self.has_creator,
+			self.display_name_is_title_case,
+			self.exactly_one_mapstart,
+			self.at_least_one_mapfinish,
+			self.spawn1_exists,
+			self.no_duplicate_spawns,
+			self.no_duplicate_wormhole_out,
+		)
+	}
+}
+
+enum Zone{
+	Start(ModeID),
+	Finish(ModeID),
+}
+
+#[derive(Debug,Hash,Eq,PartialEq)]
+struct ModeID(u64);
+impl ModeID{
+	const MAIN:Self=Self(0);
+	const BONUS:Self=Self(1);
+}
+#[allow(dead_code)]
+pub enum ZoneParseError{
+	NoCaptures,
+	ParseInt(core::num::ParseIntError)
+}
+impl std::str::FromStr for Zone{
+	type Err=ZoneParseError;
+	fn from_str(s:&str)->Result<Self,Self::Err>{
+		match s{
+			"MapStart"=>Ok(Self::Start(ModeID::MAIN)),
+			"MapFinish"=>Ok(Self::Finish(ModeID::MAIN)),
+			"BonusStart"=>Ok(Self::Start(ModeID::BONUS)),
+			"BonusFinish"=>Ok(Self::Start(ModeID::BONUS)),
+			other=>{
+				let bonus_start_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$");
+				if let Some(captures)=bonus_start_pattern.captures(other){
+					return Ok(Self::Start(ModeID(captures[1].parse().map_err(ZoneParseError::ParseInt)?)));
+				}
+				let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Finish$|^BonusFinish(\d+)$");
+				if let Some(captures)=bonus_finish_pattern.captures(other){
+					return Ok(Self::Finish(ModeID(captures[1].parse().map_err(ZoneParseError::ParseInt)?)));
+				}
+				Err(ZoneParseError::NoCaptures)
+			}
+		}
+	}
+}
+
+
+#[derive(Debug,Hash,Eq,PartialEq)]
+struct SpawnID(u64);
+impl SpawnID{
+	const FIRST:Self=Self(1);
+}
+#[derive(Debug,Hash,Eq,PartialEq)]
+struct WormholeOutID(u64);
+
+struct Counts{
+	mode_start_counts:std::collections::HashMap<ModeID,u64>,
+	mode_finish_counts:std::collections::HashMap<ModeID,u64>,
+	spawn_counts:std::collections::HashMap<SpawnID,u64>,
+	wormhole_out_counts:std::collections::HashMap<WormholeOutID,u64>,
+}
+impl Counts{
+	fn new()->Self{
+		Self{
+			mode_start_counts:std::collections::HashMap::new(),
+			mode_finish_counts:std::collections::HashMap::new(),
+			spawn_counts:std::collections::HashMap::new(),
+			wormhole_out_counts:std::collections::HashMap::new(),
+		}
+	}
+}
+
+pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{
+	// empty report with all checks failed
+	let mut report=CheckReport::default();
+
+	// extract the root instance, otherwise immediately return
+	let Ok(model_instance)=get_root_instance(&dom)else{
+		return report;
+	};
+
+	report.exactly_one_root=true;
+
+	if model_instance.class=="Model"{
+		report.root_is_model=true;
+	}
+	if model_instance.name==model_instance.name.to_snake_case(){
+		report.model_name_is_snake_case=true;
+	}
+
+	// extract model info
+	let MapInfo{display_name,creator,game_id}=get_mapinfo(&dom,model_instance);
+
+	// check DisplayName
+	if let Ok(display_name)=display_name{
+		if !display_name.is_empty(){
+			report.has_display_name=true;
+			if display_name==display_name.to_title_case(){
+				report.display_name_is_title_case=true;
+			}
+		}
+	}
+
+	// check Creator
+	if let Ok(creator)=creator{
+		if !creator.is_empty(){
+			report.has_creator=true;
+		}
+	}
+
+	// check GameID
+	if game_id.is_ok(){
+		report.model_name_prefix_is_valid=true;
+	}
+
+	// === MODE CHECKS ===
+	// count objects
+	let mut counts=Counts::new();
+	for instance in dom.descendants_of(model_instance.referent()){
+		if class_is_a(instance.class.as_str(),"BasePart"){
+			// Zones
+			match instance.name.parse(){
+				Ok(Zone::Start(mode_id))=>*counts.mode_start_counts.entry(mode_id).or_insert(0)+=1,
+				Ok(Zone::Finish(mode_id))=>*counts.mode_finish_counts.entry(mode_id).or_insert(0)+=1,
+				_=>(),
+			}
+			// Spawns
+			let spawn_pattern=lazy_regex::lazy_regex!(r"^Spawn(\d+)$");
+			if let Some(captures)=spawn_pattern.captures(instance.name.as_str()){
+				if let Ok(spawn_id)=captures[1].parse(){
+					*counts.spawn_counts.entry(SpawnID(spawn_id)).or_insert(0)+=1;
+				}
+			}
+			// WormholeOuts
+			let wormhole_out_pattern=lazy_regex::lazy_regex!(r"^WormholeOut(\d+)$");
+			if let Some(captures)=wormhole_out_pattern.captures(instance.name.as_str()){
+				if let Ok(wormhole_out_id)=captures[1].parse(){
+					*counts.wormhole_out_counts.entry(WormholeOutID(wormhole_out_id)).or_insert(0)+=1;
+				}
+			}
+		}
+	}
+
+	// MapStart must exist && there must be exactly one of any bonus start zones.
+	if counts.mode_start_counts.get(&ModeID::MAIN)==Some(&1)
+	 &&counts.mode_start_counts.iter().all(|(_,&c)|c==1){
+		report.exactly_one_mapstart=true;
+	}
+	// iterate over start zones
+	if counts.mode_start_counts.iter().all(|(mode_id,_)|
+		// ensure that at least one end zone exists with the same mode id
+		counts.mode_finish_counts.get(mode_id).is_some_and(|&num|0<num)
+	){
+		report.at_least_one_mapfinish=true;
+	}
+	// Spawn1 must exist
+	if counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
+		report.spawn1_exists=true;
+	}
+	if counts.spawn_counts.iter().all(|(_,&c)|c==1){
+		report.no_duplicate_spawns=true;
+	}
+	if counts.wormhole_out_counts.iter().all(|(_,&c)|c==1){
+		report.no_duplicate_wormhole_out=true;
+	}
+
+	report
+}
+
+pub struct CheckReportAndVersion{
+	pub report:CheckReport,
+	pub version:u64,
+}
+
+impl crate::message_handler::MessageHandler{
+	pub async fn check_inner(&self,check_info:CheckRequest)->Result<CheckReportAndVersion,Error>{
+		// discover asset creator and latest version
+		let info=self.cloud_context.get_asset_info(
+			rbx_asset::cloud::GetAssetLatestRequest{asset_id:check_info.ModelID}
+		).await.map_err(Error::ModelInfoDownload)?;
+
+		// reject models created by a group
+		let rbx_asset::cloud::Creator::userId(_user_id_string)=info.creationContext.creator else{
+			return Err(Error::CreatorTypeMustBeUser);
+		};
+
+		// parse model version string
+		let version=info.revisionId.parse().map_err(Error::ParseModelVersion)?;
+
+		let model_data=download_asset_version(&self.cloud_context,rbx_asset::cloud::GetAssetVersionRequest{
+			asset_id:check_info.ModelID,
+			version,
+		}).await.map_err(Error::Download)?;
+
+		// decode dom (slow!)
+		let dom=read_dom(std::io::Cursor::new(model_data)).map_err(Error::ModelFileDecode)?;
+
+		let report=check(&dom);
+
+		Ok(CheckReportAndVersion{report,version})
+	}
+}
diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs
new file mode 100644
index 0000000..b261f4e
--- /dev/null
+++ b/validation/src/check_mapfix.rs
@@ -0,0 +1,57 @@
+use crate::check::CheckReportAndVersion;
+use crate::nats_types::CheckMapfixRequest;
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub enum Error{
+	Check(crate::check::Error),
+	ApiActionMapfixCheck(submissions_api::Error),
+}
+impl std::fmt::Display for Error{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"{self:?}")
+	}
+}
+impl std::error::Error for Error{}
+
+impl crate::message_handler::MessageHandler{
+	pub async fn check_mapfix(&self,check_info:CheckMapfixRequest)->Result<(),Error>{
+		let mapfix_id=check_info.MapfixID;
+		let check_result=self.check_inner(check_info.into()).await;
+
+		// update the mapfix depending on the result
+		match check_result{
+			Ok(CheckReportAndVersion{report,version})=>{
+				if report.pass(){
+					// update the mapfix model status to submitted
+					self.api.action_mapfix_submitted(
+						submissions_api::types::ActionMapfixSubmittedRequest{
+							MapfixID:mapfix_id,
+							ModelVersion:version,
+						}
+					).await.map_err(Error::ApiActionMapfixCheck)?;
+				}else{
+					// update the mapfix model status to request changes
+					self.api.action_mapfix_request_changes(
+						submissions_api::types::ActionMapfixRequestChangesRequest{
+							MapfixID:mapfix_id,
+							StatusMessage:report.to_string(),
+						}
+					).await.map_err(Error::ApiActionMapfixCheck)?;
+				}
+			},
+			Err(e)=>{
+				// TODO: report the error
+				// update the mapfix model status to request changes
+				self.api.action_mapfix_request_changes(
+					submissions_api::types::ActionMapfixRequestChangesRequest{
+						MapfixID:mapfix_id,
+						StatusMessage:e.to_string(),
+					}
+				).await.map_err(Error::ApiActionMapfixCheck)?;
+			},
+		}
+
+		Ok(())
+	}
+}
diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs
new file mode 100644
index 0000000..12f18e1
--- /dev/null
+++ b/validation/src/check_submission.rs
@@ -0,0 +1,57 @@
+use crate::check::CheckReportAndVersion;
+use crate::nats_types::CheckSubmissionRequest;
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub enum Error{
+	Check(crate::check::Error),
+	ApiActionSubmissionCheck(submissions_api::Error),
+}
+impl std::fmt::Display for Error{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"{self:?}")
+	}
+}
+impl std::error::Error for Error{}
+
+impl crate::message_handler::MessageHandler{
+	pub async fn check_submission(&self,check_info:CheckSubmissionRequest)->Result<(),Error>{
+		let submission_id=check_info.SubmissionID;
+		let check_result=self.check_inner(check_info.into()).await;
+
+		// update the submission depending on the result
+		match check_result{
+			Ok(CheckReportAndVersion{report,version})=>{
+				if report.pass(){
+					// update the submission model status to submitted
+					self.api.action_submission_submitted(
+						submissions_api::types::ActionSubmissionSubmittedRequest{
+							SubmissionID:submission_id,
+							ModelVersion:version,
+						}
+					).await.map_err(Error::ApiActionSubmissionCheck)?;
+				}else{
+					// update the submission model status to request changes
+					self.api.action_submission_request_changes(
+						submissions_api::types::ActionSubmissionRequestChangesRequest{
+							SubmissionID:submission_id,
+							StatusMessage:report.to_string(),
+						}
+					).await.map_err(Error::ApiActionSubmissionCheck)?;
+				}
+			},
+			Err(e)=>{
+				// TODO: report the error
+				// update the submission model status to request changes
+				self.api.action_submission_request_changes(
+					submissions_api::types::ActionSubmissionRequestChangesRequest{
+						SubmissionID:submission_id,
+						StatusMessage:e.to_string(),
+					}
+				).await.map_err(Error::ApiActionSubmissionCheck)?;
+			},
+		}
+
+		Ok(())
+	}
+}
diff --git a/validation/src/main.rs b/validation/src/main.rs
index 2b91fdc..801ea3f 100644
--- a/validation/src/main.rs
+++ b/validation/src/main.rs
@@ -5,6 +5,9 @@ mod message_handler;
 mod nats_types;
 mod types;
 mod download;
+mod check;
+mod check_mapfix;
+mod check_submission;
 mod create;
 mod create_mapfix;
 mod create_submission;
diff --git a/validation/src/message_handler.rs b/validation/src/message_handler.rs
index 43a6a6d..fc30782 100644
--- a/validation/src/message_handler.rs
+++ b/validation/src/message_handler.rs
@@ -7,6 +7,8 @@ pub enum HandleMessageError{
 	UnknownSubject(String),
 	CreateMapfix(submissions_api::Error),
 	CreateSubmission(submissions_api::Error),
+	CheckMapfix(crate::check_mapfix::Error),
+	CheckSubmission(crate::check_submission::Error),
 	UploadMapfix(crate::upload_mapfix::Error),
 	UploadSubmission(crate::upload_submission::Error),
 	ValidateMapfix(crate::validate_mapfix::Error),
@@ -52,6 +54,8 @@ impl MessageHandler{
 		match message.subject.as_str(){
 			"maptest.mapfixes.create"=>self.create_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::CreateMapfix),
 			"maptest.submissions.create"=>self.create_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::CreateSubmission),
+			"maptest.mapfixes.check"=>self.check_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::CheckMapfix),
+			"maptest.submissions.check"=>self.check_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::CheckSubmission),
 			"maptest.mapfixes.upload"=>self.upload_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::UploadMapfix),
 			"maptest.submissions.upload"=>self.upload_submission(from_slice(&message.payload)?).await.map_err(HandleMessageError::UploadSubmission),
 			"maptest.mapfixes.validate"=>self.validate_mapfix(from_slice(&message.payload)?).await.map_err(HandleMessageError::ValidateMapfix),
diff --git a/validation/src/nats_types.rs b/validation/src/nats_types.rs
index 1401f83..a1126ae 100644
--- a/validation/src/nats_types.rs
+++ b/validation/src/nats_types.rs
@@ -20,6 +20,20 @@ pub struct CreateMapfixRequest{
 	pub TargetAssetID:u64,
 }
 
+#[allow(nonstandard_style)]
+#[derive(serde::Deserialize)]
+pub struct CheckSubmissionRequest{
+	pub SubmissionID:i64,
+	pub ModelID:u64,
+}
+
+#[allow(nonstandard_style)]
+#[derive(serde::Deserialize)]
+pub struct CheckMapfixRequest{
+	pub MapfixID:i64,
+	pub ModelID:u64,
+}
+
 #[allow(nonstandard_style)]
 #[derive(serde::Deserialize)]
 pub struct ValidateSubmissionRequest{