diff --git a/openapi.yaml b/openapi.yaml
index abb3424..69ea9bb 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1641,12 +1641,25 @@ components:
     SubmissionTriggerCreate:
       required:
       - AssetID
+      - DisplayName
+      - Creator
+      - GameID
       type: object
       properties:
         AssetID:
           type: integer
           format: int64
           minimum: 0
+        DisplayName:
+          type: string
+          maxLength: 128
+        Creator:
+          type: string
+          maxLength: 128
+        GameID:
+          type: integer
+          format: int32
+          minimum: 0
     ReleaseInfo:
       required:
       - SubmissionID
diff --git a/pkg/api/oas_json_gen.go b/pkg/api/oas_json_gen.go
index 11defac..5f84a66 100644
--- a/pkg/api/oas_json_gen.go
+++ b/pkg/api/oas_json_gen.go
@@ -3128,10 +3128,25 @@ func (s *SubmissionTriggerCreate) encodeFields(e *jx.Encoder) {
 		e.FieldStart("AssetID")
 		e.Int64(s.AssetID)
 	}
+	{
+		e.FieldStart("DisplayName")
+		e.Str(s.DisplayName)
+	}
+	{
+		e.FieldStart("Creator")
+		e.Str(s.Creator)
+	}
+	{
+		e.FieldStart("GameID")
+		e.Int32(s.GameID)
+	}
 }
 
-var jsonFieldsNameOfSubmissionTriggerCreate = [1]string{
+var jsonFieldsNameOfSubmissionTriggerCreate = [4]string{
 	0: "AssetID",
+	1: "DisplayName",
+	2: "Creator",
+	3: "GameID",
 }
 
 // Decode decodes SubmissionTriggerCreate from json.
@@ -3155,6 +3170,42 @@ func (s *SubmissionTriggerCreate) Decode(d *jx.Decoder) error {
 			}(); err != nil {
 				return errors.Wrap(err, "decode field \"AssetID\"")
 			}
+		case "DisplayName":
+			requiredBitSet[0] |= 1 << 1
+			if err := func() error {
+				v, err := d.Str()
+				s.DisplayName = string(v)
+				if err != nil {
+					return err
+				}
+				return nil
+			}(); err != nil {
+				return errors.Wrap(err, "decode field \"DisplayName\"")
+			}
+		case "Creator":
+			requiredBitSet[0] |= 1 << 2
+			if err := func() error {
+				v, err := d.Str()
+				s.Creator = string(v)
+				if err != nil {
+					return err
+				}
+				return nil
+			}(); err != nil {
+				return errors.Wrap(err, "decode field \"Creator\"")
+			}
+		case "GameID":
+			requiredBitSet[0] |= 1 << 3
+			if err := func() error {
+				v, err := d.Int32()
+				s.GameID = int32(v)
+				if err != nil {
+					return err
+				}
+				return nil
+			}(); err != nil {
+				return errors.Wrap(err, "decode field \"GameID\"")
+			}
 		default:
 			return d.Skip()
 		}
@@ -3165,7 +3216,7 @@ func (s *SubmissionTriggerCreate) Decode(d *jx.Decoder) error {
 	// Validate required fields.
 	var failures []validate.FieldError
 	for i, mask := range [1]uint8{
-		0b00000001,
+		0b00001111,
 	} {
 		if result := (requiredBitSet[i] & mask) ^ mask; result != 0 {
 			// Mask only required fields and check equality to mask using XOR.
diff --git a/pkg/api/oas_schemas_gen.go b/pkg/api/oas_schemas_gen.go
index 54d2557..31dc5ea 100644
--- a/pkg/api/oas_schemas_gen.go
+++ b/pkg/api/oas_schemas_gen.go
@@ -1318,7 +1318,10 @@ func (s *Submission) SetStatusID(val int32) {
 
 // Ref: #/components/schemas/SubmissionTriggerCreate
 type SubmissionTriggerCreate struct {
-	AssetID int64 `json:"AssetID"`
+	AssetID     int64  `json:"AssetID"`
+	DisplayName string `json:"DisplayName"`
+	Creator     string `json:"Creator"`
+	GameID      int32  `json:"GameID"`
 }
 
 // GetAssetID returns the value of AssetID.
@@ -1326,11 +1329,41 @@ func (s *SubmissionTriggerCreate) GetAssetID() int64 {
 	return s.AssetID
 }
 
+// GetDisplayName returns the value of DisplayName.
+func (s *SubmissionTriggerCreate) GetDisplayName() string {
+	return s.DisplayName
+}
+
+// GetCreator returns the value of Creator.
+func (s *SubmissionTriggerCreate) GetCreator() string {
+	return s.Creator
+}
+
+// GetGameID returns the value of GameID.
+func (s *SubmissionTriggerCreate) GetGameID() int32 {
+	return s.GameID
+}
+
 // SetAssetID sets the value of AssetID.
 func (s *SubmissionTriggerCreate) SetAssetID(val int64) {
 	s.AssetID = val
 }
 
+// SetDisplayName sets the value of DisplayName.
+func (s *SubmissionTriggerCreate) SetDisplayName(val string) {
+	s.DisplayName = val
+}
+
+// SetCreator sets the value of Creator.
+func (s *SubmissionTriggerCreate) SetCreator(val string) {
+	s.Creator = val
+}
+
+// SetGameID sets the value of GameID.
+func (s *SubmissionTriggerCreate) SetGameID(val int32) {
+	s.GameID = val
+}
+
 // Ref: #/components/schemas/Submissions
 type Submissions struct {
 	Total       int64        `json:"Total"`
diff --git a/pkg/api/oas_validators_gen.go b/pkg/api/oas_validators_gen.go
index 456bf4d..4f60361 100644
--- a/pkg/api/oas_validators_gen.go
+++ b/pkg/api/oas_validators_gen.go
@@ -1802,6 +1802,64 @@ func (s *SubmissionTriggerCreate) Validate() error {
 			Error: err,
 		})
 	}
+	if err := func() error {
+		if err := (validate.String{
+			MinLength:    0,
+			MinLengthSet: false,
+			MaxLength:    128,
+			MaxLengthSet: true,
+			Email:        false,
+			Hostname:     false,
+			Regex:        nil,
+		}).Validate(string(s.DisplayName)); err != nil {
+			return errors.Wrap(err, "string")
+		}
+		return nil
+	}(); err != nil {
+		failures = append(failures, validate.FieldError{
+			Name:  "DisplayName",
+			Error: err,
+		})
+	}
+	if err := func() error {
+		if err := (validate.String{
+			MinLength:    0,
+			MinLengthSet: false,
+			MaxLength:    128,
+			MaxLengthSet: true,
+			Email:        false,
+			Hostname:     false,
+			Regex:        nil,
+		}).Validate(string(s.Creator)); err != nil {
+			return errors.Wrap(err, "string")
+		}
+		return nil
+	}(); err != nil {
+		failures = append(failures, validate.FieldError{
+			Name:  "Creator",
+			Error: err,
+		})
+	}
+	if err := func() error {
+		if err := (validate.Int{
+			MinSet:        true,
+			Min:           0,
+			MaxSet:        false,
+			Max:           0,
+			MinExclusive:  false,
+			MaxExclusive:  false,
+			MultipleOfSet: false,
+			MultipleOf:    0,
+		}).Validate(int64(s.GameID)); err != nil {
+			return errors.Wrap(err, "int")
+		}
+		return nil
+	}(); err != nil {
+		failures = append(failures, validate.FieldError{
+			Name:  "GameID",
+			Error: err,
+		})
+	}
 	if len(failures) > 0 {
 		return &validate.Error{Fields: failures}
 	}
diff --git a/pkg/model/nats.go b/pkg/model/nats.go
index 91c0453..0d422af 100644
--- a/pkg/model/nats.go
+++ b/pkg/model/nats.go
@@ -9,6 +9,9 @@ type CreateSubmissionRequest struct {
 	// operation_id is passed back in the response message
 	OperationID    int32
 	ModelID       uint64
+	DisplayName   string
+	Creator       string
+	GameID        uint32
 }
 
 type CreateMapfixRequest struct {
diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go
index 5af693f..c1ce44c 100644
--- a/pkg/service/submissions.go
+++ b/pkg/service/submissions.go
@@ -101,8 +101,11 @@ func (svc *Service) CreateSubmission(ctx context.Context, request *api.Submissio
 	}
 
 	create_request := model.CreateSubmissionRequest{
-		OperationID:   operation.ID,
-		ModelID:       ModelID,
+		OperationID: operation.ID,
+		ModelID:     ModelID,
+		DisplayName: request.DisplayName,
+		Creator:     request.Creator,
+		GameID:      uint32(request.GameID),
 	}
 
 	j, err := json.Marshal(create_request)
diff --git a/validation/api/src/internal.rs b/validation/api/src/internal.rs
index d689981..fb418f6 100644
--- a/validation/api/src/internal.rs
+++ b/validation/api/src/internal.rs
@@ -167,6 +167,9 @@ impl Context{
 	);
 	action!("submissions",action_submission_submitted,config,ActionSubmissionSubmittedRequest,"status/validator-submitted",config.SubmissionID,
 		("ModelVersion",config.ModelVersion.to_string().as_str())
+		("DisplayName",config.DisplayName.as_str())
+		("Creator",config.Creator.as_str())
+		("GameID",config.GameID.to_string().as_str())
 	);
 	action!("submissions",action_submission_validated,config,SubmissionID,"status/validator-validated",config.0,);
 	action!("submissions",update_submission_validated_model,config,UpdateSubmissionModelRequest,"validated-model",config.SubmissionID,
@@ -196,6 +199,9 @@ impl Context{
 	);
 	action!("mapfixes",action_mapfix_submitted,config,ActionMapfixSubmittedRequest,"status/validator-submitted",config.MapfixID,
 		("ModelVersion",config.ModelVersion.to_string().as_str())
+		("DisplayName",config.DisplayName.as_str())
+		("Creator",config.Creator.as_str())
+		("GameID",config.GameID.to_string().as_str())
 	);
 	action!("mapfixes",action_mapfix_validated,config,MapfixID,"status/validator-validated",config.0,);
 	action!("mapfixes",update_mapfix_validated_model,config,UpdateMapfixModelRequest,"validated-model",config.MapfixID,
diff --git a/validation/api/src/types.rs b/validation/api/src/types.rs
index 2a329fc..eb4e87e 100644
--- a/validation/api/src/types.rs
+++ b/validation/api/src/types.rs
@@ -228,6 +228,9 @@ pub struct UpdateSubmissionModelRequest{
 pub struct ActionSubmissionSubmittedRequest{
 	pub SubmissionID:i64,
 	pub ModelVersion:u64,
+	pub DisplayName:String,
+	pub Creator:String,
+	pub GameID:u32,
 }
 
 #[allow(nonstandard_style)]
@@ -267,6 +270,9 @@ pub struct UpdateMapfixModelRequest{
 pub struct ActionMapfixSubmittedRequest{
 	pub MapfixID:i64,
 	pub ModelVersion:u64,
+	pub DisplayName:String,
+	pub Creator:String,
+	pub GameID:u32,
 }
 
 #[allow(nonstandard_style)]
diff --git a/validation/src/check.rs b/validation/src/check.rs
index 0aee2b2..0b428fa 100644
--- a/validation/src/check.rs
+++ b/validation/src/check.rs
@@ -1,5 +1,6 @@
+use std::collections::{HashSet,HashMap};
 use crate::download::download_asset_version;
-use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,MapInfo,ReadDomError};
+use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
 
 use heck::{ToSnakeCase,ToTitleCase};
 
@@ -10,6 +11,7 @@ pub enum Error{
 	CreatorTypeMustBeUser,
 	Download(crate::download::Error),
 	ModelFileDecode(ReadDomError),
+	GetRootInstance(GetRootInstanceError),
 }
 impl std::fmt::Display for Error{
 	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
@@ -38,117 +40,30 @@ impl From<crate::nats_types::CheckSubmissionRequest> for CheckRequest{
 	}
 }
 
-#[derive(Default)]
-pub enum Check{
-	Pass,
-	#[default]
-	Fail,
-}
-impl Check{
-	fn pass(&self)->bool{
-		match self{
-			Check::Pass=>true,
-			Check::Fail=>false,
-		}
-	}
-}
-impl std::fmt::Display for Check{
-	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
-		match self{
-			Check::Pass=>write!(f,"passed"),
-			Check::Fail=>write!(f,"failed"),
-		}
-	}
-}
-
-#[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:Check,
-	// the root must be of class Model
-	root_is_model:Check,
-	// 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:Check,
-	// your model's name must match this regex: ^[a-z0-9_]
-	model_name_is_snake_case:Check,
-	// map must have a StringValue named Creator and DisplayName. additionally, they must both have a value
-	has_display_name:Check,
-	has_creator:Check,
-	// the display name must be capitalized
-	display_name_is_title_case:Check,
-	// 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:Check,
-	// At least one MapFinish
-	at_least_one_mapfinish:Check,
-	// Spawn0 or Spawn1 must exist
-	spawn1_exists:Check,
-	// No duplicate Spawn#
-	no_duplicate_spawns:Check,
-	// No duplicate WormholeOut# (duplicate WormholeIn# ok)
-	no_duplicate_wormhole_out:Check,
-}
-impl CheckReport{
-	pub fn pass(&self)->bool{
-		return self.exactly_one_root.pass()
-			 &&self.root_is_model.pass()
-			 &&self.model_name_prefix_is_valid.pass()
-			 &&self.model_name_is_snake_case.pass()
-			 &&self.has_display_name.pass()
-			 &&self.has_creator.pass()
-			 &&self.display_name_is_title_case.pass()
-			 &&self.exactly_one_mapstart.pass()
-			 &&self.at_least_one_mapfinish.pass()
-			 &&self.spawn1_exists.pass()
-			 &&self.no_duplicate_spawns.pass()
-			 &&self.no_duplicate_wormhole_out.pass()
-	}
-}
-impl std::fmt::Display for CheckReport{
-	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
-		write!(f,
-			"exactly_one_root={}\n
-root_is_model={}\n
-model_name_prefix_is_valid={}\n
-model_name_is_snake_case={}\n
-has_display_name={}\n
-has_creator={}\n
-display_name_is_title_case={}\n
-exactly_one_mapstart={}\n
-at_least_one_mapfinish={}\n
-spawn1_exists={}\n
-no_duplicate_spawns={}\n
-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)]
+#[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)]
 struct ModeID(u64);
 impl ModeID{
 	const MAIN:Self=Self(0);
 	const BONUS:Self=Self(1);
+	fn write_start_zone(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		match self{
+			ModeID(0)=>write!(f,"MapStart"),
+			ModeID(1)=>write!(f,"BonusStart"),
+			ModeID(other)=>write!(f,"Bonus{other}Start"),
+		}
+	}
+	fn write_finish_zone(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		match self{
+			ModeID(0)=>write!(f,"MapFinish"),
+			ModeID(1)=>write!(f,"BonusFinish"),
+			ModeID(other)=>write!(f,"Bonus{other}Finish"),
+		}
+	}
 }
 #[allow(dead_code)]
 pub enum ZoneParseError{
@@ -188,66 +103,37 @@ impl SpawnID{
 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>,
+	mode_start_counts:HashMap<ModeID,u64>,
+	mode_finish_counts:HashMap<ModeID,u64>,
+	spawn_counts:HashMap<SpawnID,u64>,
+	wormhole_out_counts: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(),
+			mode_start_counts:HashMap::new(),
+			mode_finish_counts:HashMap::new(),
+			spawn_counts:HashMap::new(),
+			wormhole_out_counts:HashMap::new(),
 		}
 	}
 }
 
-pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{
-	// empty report with all checks failed
-	let mut report=CheckReport::default();
 
+pub struct ModelInfo<'a>{
+	model_class:&'a str,
+	model_name:&'a str,
+	map_info:MapInfo<'a>,
+	counts:Counts,
+}
+
+pub fn get_model_info(dom:&rbx_dom_weak::WeakDom)->Result<ModelInfo,GetRootInstanceError>{
 	// extract the root instance, otherwise immediately return
-	let Ok(model_instance)=get_root_instance(&dom)else{
-		return report;
-	};
-
-	report.exactly_one_root=Check::Pass;
-
-	if model_instance.class=="Model"{
-		report.root_is_model=Check::Pass;
-	}
-	if model_instance.name==model_instance.name.to_snake_case(){
-		report.model_name_is_snake_case=Check::Pass;
-	}
+	let model_instance=get_root_instance(&dom)?;
 
 	// extract model info
-	let MapInfo{display_name,creator,game_id}=get_mapinfo(&dom,model_instance);
+	let map_info=get_mapinfo(&dom,model_instance);
 
-	// check DisplayName
-	if let Ok(display_name)=display_name{
-		if !display_name.is_empty(){
-			report.has_display_name=Check::Pass;
-			if display_name==display_name.to_title_case(){
-				report.display_name_is_title_case=Check::Pass;
-			}
-		}
-	}
-
-	// check Creator
-	if let Ok(creator)=creator{
-		if !creator.is_empty(){
-			report.has_creator=Check::Pass;
-		}
-	}
-
-	// check GameID
-	if game_id.is_ok(){
-		report.model_name_prefix_is_valid=Check::Pass;
-	}
-
-	// === MODE CHECKS ===
 	// count objects
 	let mut counts=Counts::new();
 	for instance in dom.descendants_of(model_instance.referent()){
@@ -275,34 +161,318 @@ pub fn check(dom:&rbx_dom_weak::WeakDom)->CheckReport{
 		}
 	}
 
-	// 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=Check::Pass;
-	}
-	// 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=Check::Pass;
-	}
-	// Spawn1 must exist
-	if counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
-		report.spawn1_exists=Check::Pass;
-	}
-	if counts.spawn_counts.iter().all(|(_,&c)|c==1){
-		report.no_duplicate_spawns=Check::Pass;
-	}
-	if counts.wormhole_out_counts.iter().all(|(_,&c)|c==1){
-		report.no_duplicate_wormhole_out=Check::Pass;
-	}
+	Ok(ModelInfo{
+		model_class:model_instance.class.as_str(),
+		model_name:model_instance.name.as_str(),
+		map_info,
+		counts,
+	})
+}
 
-	report
+// check if an observed string matches and expected string
+pub struct StringCheck<'a,T,Str>(Result<T,StringCheckContext<'a,Str>>);
+pub struct StringCheckContext<'a,Str>{
+	observed:&'a str,
+	expected:Str,
+}
+impl<'a,Str> StringCheckContext<'a,Str>
+	where
+		&'a str:PartialEq<Str>,
+{
+	fn check<T>(self,value:T)->StringCheck<'a,T,Str>{
+		if self.observed==self.expected{
+			StringCheck(Ok(value))
+		}else{
+			StringCheck(Err(self))
+		}
+	}
+}
+impl<'a,Str:std::fmt::Display> std::fmt::Display for StringCheckContext<'a,Str>{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"expected: {}, observed: {}",self.expected,self.observed)
+	}
+}
+
+// check if a string is empty
+pub struct StringEmpty;
+pub struct StringEmptyCheck<Context>(Result<Context,StringEmpty>);
+impl std::fmt::Display for StringEmpty{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"Empty string")
+	}
+}
+
+// check for duplicate objects
+pub struct DuplicateCheckContext<ID>(HashMap<ID,u64>);
+pub struct DuplicateCheck<ID>(Result<(),DuplicateCheckContext<ID>>);
+impl<ID> DuplicateCheckContext<ID>{
+	fn check(self)->DuplicateCheck<ID>{
+		let Self(mut set)=self;
+		// remove correct entries
+		set.retain(|_,&mut c|c!=1);
+		// if any entries remain, they are incorrect
+		if set.is_empty(){
+			DuplicateCheck(Ok(()))
+		}else{
+			DuplicateCheck(Err(Self(set)))
+		}
+	}
+}
+
+// check that there is at least one
+pub struct AtLeastOneMatchingAndNoExtraCheckContext<ID>{
+	extra:HashMap<ID,u64>,
+	missing:HashSet<ID>,
+}
+pub struct AtLeastOneMatchingAndNoExtraCheck<ID>(Result<(),AtLeastOneMatchingAndNoExtraCheckContext<ID>>);
+impl<ID> AtLeastOneMatchingAndNoExtraCheckContext<ID>{
+	fn new(initial_set:HashMap<ID,u64>)->Self{
+		Self{
+			extra:initial_set,
+			missing:HashSet::new(),
+		}
+	}
+}
+impl<ID:Copy+Eq+std::hash::Hash> AtLeastOneMatchingAndNoExtraCheckContext<ID>{
+	fn check<T>(self,reference_set:&HashMap<ID,T>)->AtLeastOneMatchingAndNoExtraCheck<ID>{
+		let Self{mut extra,mut missing}=self;
+		// remove correct entries
+		for (id,_) in reference_set{
+			if extra.remove(id).is_none(){
+				// the set did not contain a required item.  This is a fail
+				missing.insert(*id);
+			}
+		}
+		// if any entries remain, they are incorrect
+		if extra.is_empty()&&missing.is_empty(){
+			AtLeastOneMatchingAndNoExtraCheck(Ok(()))
+		}else{
+			AtLeastOneMatchingAndNoExtraCheck(Err(Self{extra,missing}))
+		}
+	}
+}
+
+pub struct MapInfoOwned{
+	pub display_name:String,
+	pub creator:String,
+	pub game_id:GameID,
+}
+
+// crazy!
+pub struct MapCheck<'a>{
+	model_class:StringCheck<'a,(),&'a str>,
+	model_name:StringCheck<'a,(),String>,
+	display_name:Result<StringEmptyCheck<StringCheck<'a,&'a str,String>>,StringValueError>,
+	creator:Result<StringEmptyCheck<&'a str>,StringValueError>,
+	game_id:Result<GameID,ParseGameIDError>,
+	mapstart:Result<(),()>,
+	mode_start_counts:DuplicateCheck<ModeID>,
+	mode_finish_counts:AtLeastOneMatchingAndNoExtraCheck<ModeID>,
+	spawn1:Result<(),()>,
+	spawn_counts:DuplicateCheck<SpawnID>,
+	wormhole_out_counts:DuplicateCheck<WormholeOutID>,
+}
+
+impl<'a> ModelInfo<'a>{
+	fn check(self)->MapCheck<'a>{
+		let model_class=StringCheckContext{
+			observed:self.model_class,
+			expected:"Model",
+		}.check(());
+
+		let model_name=StringCheckContext{
+			observed:self.model_name,
+			expected:self.model_name.to_snake_case(),
+		}.check(());
+
+		// check display name
+		let display_name=self.map_info.display_name.map(|display_name|{
+			if display_name.is_empty(){
+				StringEmptyCheck(Err(StringEmpty))
+			}else{
+				StringEmptyCheck(Ok(StringCheckContext{
+					observed:display_name,
+					expected:display_name.to_title_case(),
+				}.check(display_name)))
+			}
+		});
+
+		// check Creator
+		let creator=self.map_info.creator.map(|creator|{
+			if creator.is_empty(){
+				StringEmptyCheck(Err(StringEmpty))
+			}else{
+				StringEmptyCheck(Ok(creator))
+			}
+		});
+
+		// check GameID
+		let game_id=self.map_info.game_id;
+
+		// MapStart must exist
+		let mapstart=if self.counts.mode_start_counts.get(&ModeID::MAIN).is_some(){
+			Ok(())
+		}else{
+			Err(())
+		};
+
+		// Spawn1 must exist
+		let spawn1=if self.counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
+			Ok(())
+		}else{
+			Err(())
+		};
+
+		// check that at least one end zone exists for each start zone.
+		let mode_finish_counts=AtLeastOneMatchingAndNoExtraCheckContext::new(self.counts.mode_finish_counts)
+			.check(&self.counts.mode_start_counts);
+
+		// there must be exactly one start zone for every mode in the map.
+		let mode_start_counts=DuplicateCheckContext(self.counts.mode_start_counts).check();
+
+		// there must be exactly one of any perticular spawn id in the map.
+		let spawn_counts=DuplicateCheckContext(self.counts.spawn_counts).check();
+
+		// there must be exactly one of any perticular wormhole out id in the map.
+		let wormhole_out_counts=DuplicateCheckContext(self.counts.wormhole_out_counts).check();
+
+		MapCheck{
+			model_class,
+			model_name,
+			display_name,
+			creator,
+			game_id,
+			mapstart,
+			mode_start_counts,
+			mode_finish_counts,
+			spawn1,
+			spawn_counts,
+			wormhole_out_counts,
+		}
+	}
+}
+
+impl<'a> MapCheck<'a>{
+	fn pass(self)->Result<MapInfoOwned,Self>{
+		match self{
+			MapCheck{
+				model_class:StringCheck(Ok(())),
+				model_name:StringCheck(Ok(())),
+				display_name:Ok(StringEmptyCheck(Ok(StringCheck(Ok(display_name))))),
+				creator:Ok(StringEmptyCheck(Ok(creator))),
+				game_id:Ok(game_id),
+				mapstart:Ok(()),
+				mode_start_counts:DuplicateCheck(Ok(())),
+				mode_finish_counts:AtLeastOneMatchingAndNoExtraCheck(Ok(())),
+				spawn1:Ok(()),
+				spawn_counts:DuplicateCheck(Ok(())),
+				wormhole_out_counts:DuplicateCheck(Ok(())),
+			}=>{
+				Ok(MapInfoOwned{
+					display_name:display_name.to_owned(),
+					creator:creator.to_owned(),
+					game_id,
+				})
+			},
+			other=>Err(other),
+		}
+	}
+}
+
+fn comma_separated<T,F>(f:&mut std::fmt::Formatter<'_>,mut it:impl Iterator<Item=T>,custom_write:F)->std::fmt::Result
+where
+	F:Fn(&mut std::fmt::Formatter<'_>,T)->std::fmt::Result,
+{
+	if let Some(t)=it.next(){
+		custom_write(f,t)?;
+		for t in it{
+			write!(f,", ")?;
+			custom_write(f,t)?;
+		}
+	}
+	Ok(())
+}
+
+impl<'a> std::fmt::Display for MapCheck<'a>{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		if let StringCheck(Err(context))=&self.model_class{
+			writeln!(f,"Invalid Model Class: {context}")?;
+		}
+		if let StringCheck(Err(context))=&self.model_name{
+			writeln!(f,"Invalid Model Name: {context}")?;
+		}
+		match &self.display_name{
+			Ok(StringEmptyCheck(Ok(StringCheck(Ok(_)))))=>(),
+			Ok(StringEmptyCheck(Ok(StringCheck(Err(context)))))=>writeln!(f,"Invalid DisplayName: {context}")?,
+			Ok(StringEmptyCheck(Err(context)))=>writeln!(f,"Invalid DisplayName: {context}")?,
+			Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing DisplayName StringValue")?,
+			Err(StringValueError::ValueNotSet)=>writeln!(f,"DisplayName Value not set")?,
+			Err(StringValueError::NonStringValue)=>writeln!(f,"DisplayName Value is not a String")?,
+		}
+		match &self.creator{
+			Ok(StringEmptyCheck(Ok(_)))=>(),
+			Ok(StringEmptyCheck(Err(context)))=>writeln!(f,"Invalid Creator: {context}")?,
+			Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing Creator StringValue")?,
+			Err(StringValueError::ValueNotSet)=>writeln!(f,"Creator Value not set")?,
+			Err(StringValueError::NonStringValue)=>writeln!(f,"Creator Value is not a String")?,
+		}
+		if let Err(_parse_game_id_error)=&self.game_id{
+			writeln!(f,"Model name must be prefixed with bhop_ surf_ or flytrials_")?;
+		}
+		if let Err(())=&self.mapstart{
+			writeln!(f,"Model has no MapStart")?;
+		}
+		if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.mode_start_counts{
+			write!(f,"Duplicate start zones: ")?;
+			comma_separated(f,context.iter(),|f,(mode_id,count)|{
+				mode_id.write_start_zone(f)?;
+				write!(f,"({count} duplicates)")?;
+				Ok(())
+			})?;
+			writeln!(f,"")?;
+		}
+		if let AtLeastOneMatchingAndNoExtraCheck(Err(context))=&self.mode_finish_counts{
+			// perhaps there are extra end zones (context.extra)
+			if !context.extra.is_empty(){
+				write!(f,"Extra finish zones with no matching start zone: ")?;
+				comma_separated(f,context.extra.iter(),|f,(mode_id,_count)|
+					mode_id.write_finish_zone(f))?;
+				writeln!(f,"")?;
+			}
+			// perhaps there are missing end zones (context.missing)
+			if !context.missing.is_empty(){
+				write!(f,"Missing finish zones: ")?;
+				comma_separated(f,context.missing.iter(),|f,mode_id|
+					mode_id.write_finish_zone(f)
+				)?;
+				writeln!(f,"")?;
+			}
+		}
+		if let Err(())=&self.spawn1{
+			writeln!(f,"Model has no Spawn1")?;
+		}
+		if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.spawn_counts{
+			write!(f,"Duplicate spawn zones: ")?;
+			comma_separated(f,context.iter(),|f,(SpawnID(spawn_id),count)|{
+				write!(f,"Spawn{spawn_id}({count} duplicates)")?;
+				Ok(())
+			})?;
+			writeln!(f,"")?;
+		}
+		if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.wormhole_out_counts{
+			write!(f,"Duplicate wormhole out: ")?;
+			comma_separated(f,context.iter(),|f,(WormholeOutID(wormhole_out_id),count)|{
+				write!(f,"WormholeOut{wormhole_out_id}({count} duplicates)")?;
+				Ok(())
+			})?;
+			writeln!(f,"")?;
+		}
+		Ok(())
+	}
 }
 
 pub struct CheckReportAndVersion{
-	pub report:CheckReport,
+	pub status:Result<MapInfoOwned,String>,
 	pub version:u64,
 }
 
@@ -329,8 +499,15 @@ impl crate::message_handler::MessageHandler{
 		// decode dom (slow!)
 		let dom=maybe_gzip.read_with(read_dom,read_dom).map_err(Error::ModelFileDecode)?;
 
-		let report=check(&dom);
+		// extract information from the model
+		let model_info=get_model_info(&dom).map_err(Error::GetRootInstance)?;
 
-		Ok(CheckReportAndVersion{report,version})
+		// convert the model information into a structured report
+		let map_check=model_info.check();
+
+		// check the report, generate an error message if it fails the check
+		let status=map_check.pass().map_err(|e|e.to_string());
+
+		Ok(CheckReportAndVersion{status,version})
 	}
 }
diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs
index a562ed0..2187fe3 100644
--- a/validation/src/check_mapfix.rs
+++ b/validation/src/check_mapfix.rs
@@ -21,23 +21,27 @@ impl crate::message_handler::MessageHandler{
 
 		// update the mapfix depending on the result
 		match check_result{
-			Ok(CheckReportAndVersion{report,version})=>{
-				if report.pass(){
+			Ok(CheckReportAndVersion{status,version})=>{
+				match status{
 					// update the mapfix model status to submitted
+					Ok(map_info)=>
 					self.api.action_mapfix_submitted(
 						submissions_api::types::ActionMapfixSubmittedRequest{
 							MapfixID:mapfix_id,
 							ModelVersion:version,
+							DisplayName:map_info.display_name,
+							Creator:map_info.creator,
+							GameID:map_info.game_id as u32,
 						}
-					).await.map_err(Error::ApiActionMapfixCheck)?;
-				}else{
+					).await.map_err(Error::ApiActionMapfixCheck)?,
 					// update the mapfix model status to request changes
+					Err(report)=>
 					self.api.action_mapfix_request_changes(
 						submissions_api::types::ActionMapfixRequestChangesRequest{
 							MapfixID:mapfix_id,
-							ErrorMessage:report.to_string(),
+							ErrorMessage:report,
 						}
-					).await.map_err(Error::ApiActionMapfixCheck)?;
+					).await.map_err(Error::ApiActionMapfixCheck)?,
 				}
 			},
 			Err(e)=>{
diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs
index 05d0f18..d0bd80c 100644
--- a/validation/src/check_submission.rs
+++ b/validation/src/check_submission.rs
@@ -21,23 +21,27 @@ impl crate::message_handler::MessageHandler{
 
 		// update the submission depending on the result
 		match check_result{
-			Ok(CheckReportAndVersion{report,version})=>{
-				if report.pass(){
+			Ok(CheckReportAndVersion{status,version})=>{
+				match status{
 					// update the submission model status to submitted
+					Ok(map_info)=>
 					self.api.action_submission_submitted(
 						submissions_api::types::ActionSubmissionSubmittedRequest{
 							SubmissionID:submission_id,
 							ModelVersion:version,
+							DisplayName:map_info.display_name,
+							Creator:map_info.creator,
+							GameID:map_info.game_id as u32,
 						}
-					).await.map_err(Error::ApiActionSubmissionCheck)?;
-				}else{
+					).await.map_err(Error::ApiActionSubmissionCheck)?,
 					// update the submission model status to request changes
+					Err(report)=>
 					self.api.action_submission_request_changes(
 						submissions_api::types::ActionSubmissionRequestChangesRequest{
 							SubmissionID:submission_id,
-							ErrorMessage:report.to_string(),
+							ErrorMessage:report,
 						}
-					).await.map_err(Error::ApiActionSubmissionCheck)?;
+					).await.map_err(Error::ApiActionSubmissionCheck)?,
 				}
 			},
 			Err(e)=>{
diff --git a/validation/src/create.rs b/validation/src/create.rs
index c6ea948..f29d4ca 100644
--- a/validation/src/create.rs
+++ b/validation/src/create.rs
@@ -1,5 +1,5 @@
 use crate::download::download_asset_version;
-use crate::rbx_util::{get_root_instance,get_mapinfo,read_dom,MapInfo,ReadDomError,GetRootInstanceError,ParseGameIDError};
+use crate::rbx_util::{get_root_instance,get_mapinfo,read_dom,MapInfo,ReadDomError,GetRootInstanceError,GameID};
 
 #[allow(dead_code)]
 #[derive(Debug)]
@@ -9,7 +9,6 @@ pub enum Error{
 	Download(crate::download::Error),
 	ModelFileDecode(ReadDomError),
 	GetRootInstance(GetRootInstanceError),
-	ParseGameID(ParseGameIDError),
 }
 impl std::fmt::Display for Error{
 	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
@@ -24,10 +23,10 @@ pub struct CreateRequest{
 }
 #[allow(nonstandard_style)]
 pub struct CreateResult{
-	pub AssetOwner:i64,
-	pub DisplayName:String,
-	pub Creator:String,
-	pub GameID:i32,
+	pub AssetOwner:u64,
+	pub DisplayName:Option<String>,
+	pub Creator:Option<String>,
+	pub GameID:Option<GameID>,
 	pub AssetVersion:u64,
 }
 impl crate::message_handler::MessageHandler{
@@ -63,13 +62,11 @@ impl crate::message_handler::MessageHandler{
 			game_id,
 		}=get_mapinfo(&dom,model_instance);
 
-		let game_id=game_id.map_err(Error::ParseGameID)?;
-
 		Ok(CreateResult{
-			AssetOwner:user_id as i64,
-			DisplayName:display_name.unwrap_or_default().to_owned(),
-			Creator:creator.unwrap_or_default().to_owned(),
-			GameID:game_id as i32,
+			AssetOwner:user_id,
+			DisplayName:display_name.ok().map(ToOwned::to_owned),
+			Creator:creator.ok().map(ToOwned::to_owned),
+			GameID:game_id.ok(),
 			AssetVersion:asset_version,
 		})
 	}
diff --git a/validation/src/create_mapfix.rs b/validation/src/create_mapfix.rs
index 010df43..9980c53 100644
--- a/validation/src/create_mapfix.rs
+++ b/validation/src/create_mapfix.rs
@@ -24,10 +24,11 @@ impl crate::message_handler::MessageHandler{
 		// call create on api
 		self.api.create_mapfix(submissions_api::types::CreateMapfixRequest{
 			OperationID:create_info.OperationID,
-			AssetOwner:create_request.AssetOwner,
-			DisplayName:create_request.DisplayName.as_str(),
-			Creator:create_request.Creator.as_str(),
-			GameID:create_request.GameID,
+			AssetOwner:create_request.AssetOwner as i64,
+			DisplayName:create_request.DisplayName.as_deref().unwrap_or_default(),
+			Creator:create_request.Creator.as_deref().unwrap_or_default(),
+			// not great TODO: make this great
+			GameID:create_request.GameID.unwrap_or(crate::rbx_util::GameID::Bhop) as i32,
 			AssetID:create_info.ModelID,
 			AssetVersion:create_request.AssetVersion,
 			TargetAssetID:create_info.TargetAssetID,
diff --git a/validation/src/create_submission.rs b/validation/src/create_submission.rs
index 370e80d..06485d4 100644
--- a/validation/src/create_submission.rs
+++ b/validation/src/create_submission.rs
@@ -1,5 +1,6 @@
 use crate::nats_types::CreateSubmissionRequest;
 use crate::create::CreateRequest;
+use crate::rbx_util::GameID;
 
 #[allow(dead_code)]
 #[derive(Debug)]
@@ -19,13 +20,29 @@ impl crate::message_handler::MessageHandler{
 		let create_request=self.create_inner(CreateRequest{
 			ModelID:create_info.ModelID,
 		}).await.map_err(Error::Create)?;
+
+		// grab values from submission form, otherwise try to fill blanks from map data
+		let display_name=if create_info.DisplayName.is_empty(){
+			create_request.DisplayName.as_deref().unwrap_or_default()
+		}else{
+			create_info.DisplayName.as_str()
+		};
+
+		let creator=if create_info.Creator.is_empty(){
+			create_request.Creator.as_deref().unwrap_or_default()
+		}else{
+			create_info.Creator.as_str()
+		};
+
+		let game_id=create_info.GameID.try_into().ok().or(create_request.GameID).unwrap_or(GameID::Bhop);
+
 		// call create on api
 		self.api.create_submission(submissions_api::types::CreateSubmissionRequest{
 			OperationID:create_info.OperationID,
-			AssetOwner:create_request.AssetOwner,
-			DisplayName:create_request.DisplayName.as_str(),
-			Creator:create_request.Creator.as_str(),
-			GameID:create_request.GameID,
+			AssetOwner:create_request.AssetOwner as i64,
+			DisplayName:display_name,
+			Creator:creator,
+			GameID:game_id as i32,
 			AssetID:create_info.ModelID,
 			AssetVersion:create_request.AssetVersion,
 		}).await.map_err(Error::ApiActionSubmissionCreate)?;
diff --git a/validation/src/nats_types.rs b/validation/src/nats_types.rs
index bca6646..357e69b 100644
--- a/validation/src/nats_types.rs
+++ b/validation/src/nats_types.rs
@@ -10,6 +10,9 @@ pub struct CreateSubmissionRequest{
 	// operation_id is passed back in the response message
 	pub OperationID:i32,
 	pub ModelID:u64,
+	pub DisplayName:String,
+	pub Creator:String,
+	pub GameID:u32,
 }
 
 #[allow(nonstandard_style)]
diff --git a/validation/src/rbx_util.rs b/validation/src/rbx_util.rs
index 92f8fa4..db50048 100644
--- a/validation/src/rbx_util.rs
+++ b/validation/src/rbx_util.rs
@@ -70,6 +70,18 @@ impl std::str::FromStr for GameID{
 		return Err(ParseGameIDError);
 	}
 }
+pub struct GameIDError;
+impl TryFrom<u32> for GameID{
+	type Error=GameIDError;
+	fn try_from(value:u32)->Result<Self,Self::Error>{
+		match value{
+			1=>Ok(GameID::Bhop),
+			2=>Ok(GameID::Surf),
+			5=>Ok(GameID::FlyTrials),
+			_=>Err(GameIDError)
+		}
+	}
+}
 
 pub struct MapInfo<'a>{
 	pub display_name:Result<&'a str,StringValueError>,
@@ -77,6 +89,7 @@ pub struct MapInfo<'a>{
 	pub game_id:Result<GameID,ParseGameIDError>,
 }
 
+#[derive(Debug)]
 pub enum StringValueError{
 	ObjectNotFound,
 	ValueNotSet,
diff --git a/web/src/app/submit/_game.tsx b/web/src/app/submit/_game.tsx
new file mode 100644
index 0000000..e754601
--- /dev/null
+++ b/web/src/app/submit/_game.tsx
@@ -0,0 +1,65 @@
+import { FormControl, Select, InputLabel, MenuItem } from "@mui/material";
+import { styled } from '@mui/material/styles';
+import InputBase from '@mui/material/InputBase';
+import React from "react";
+import { SelectChangeEvent } from "@mui/material";
+
+// TODO: Properly style everything instead of pasting 🤚
+
+type GameSelectionProps = {
+	game: number;
+	setGame: React.Dispatch<React.SetStateAction<number>>;
+};
+
+const BootstrapInput = styled(InputBase)(({ theme }) => ({
+	'label + &': {
+	  marginTop: theme.spacing(3),
+	},
+	'& .MuiInputBase-input': {
+	  backgroundColor: '#0000',
+	  color: '#FFF',
+	  border: '1px solid rgba(175, 175, 175, 0.66)',
+	  fontSize: 16,
+	  padding: '10px 26px 10px 12px',
+	  transition: theme.transitions.create(['border-color', 'box-shadow']),
+	  fontFamily: [
+		'-apple-system',
+		'BlinkMacSystemFont',
+		'"Segoe UI"',
+		'Roboto',
+		'"Helvetica Neue"',
+		'Arial',
+		'sans-serif',
+		'"Apple Color Emoji"',
+		'"Segoe UI Emoji"',
+		'"Segoe UI Symbol"',
+	  ].join(','),
+	  '&:focus': {
+		borderRadius: 4,
+		borderColor: '#80bdff',
+		boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)',
+	  },
+	},
+  }));
+
+export default function GameSelection({ game, setGame }: GameSelectionProps) {
+	const handleChange = (event: SelectChangeEvent) => {
+		setGame(Number(event.target.value)); // TODO: Change later!! there's 100% a proper way of doing this
+	};
+
+	return (
+		<FormControl>
+			<InputLabel sx={{ color: "#646464" }}>Game</InputLabel>
+			<Select
+				value={String(game)}
+				label="Game"
+				onChange={handleChange}
+				input={<BootstrapInput />}
+			>
+				<MenuItem value={1}>Bhop</MenuItem>
+				<MenuItem value={2}>Surf</MenuItem>
+				<MenuItem value={3}>Fly Trials</MenuItem>
+			</Select>
+		</FormControl>
+	);
+}
\ No newline at end of file
diff --git a/web/src/app/submit/page.tsx b/web/src/app/submit/page.tsx
index a0b09ee..b0a71e9 100644
--- a/web/src/app/submit/page.tsx
+++ b/web/src/app/submit/page.tsx
@@ -2,19 +2,25 @@
 
 import { Button, TextField } from "@mui/material"
 
+import GameSelection from "./_game";
 import SendIcon from '@mui/icons-material/Send';
 import Webpage from "@/app/_components/webpage"
+import React, { useState } from "react";
 
 import "./(styles)/page.scss"
 
 interface SubmissionPayload {
 	AssetID: number;
+	DisplayName: string;
+	Creator: string;
+	GameID: number;
 }
 interface IdResponse {
 	OperationID: number;
 }
 
 export default function SubmissionInfoPage() {
+	const [game, setGame] = useState(1);
 
 	const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
 		event.preventDefault();
@@ -23,6 +29,9 @@ export default function SubmissionInfoPage() {
 		const formData = new FormData(form);
 
 		const payload: SubmissionPayload = {
+			DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change
+			Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change
+			GameID: game,
 			AssetID: Number((formData.get("asset-id") as string) ?? "0"),
 		};
 
@@ -64,7 +73,10 @@ export default function SubmissionInfoPage() {
 					<span className="spacer form-spacer"></span>
 				</header>
 				<form onSubmit={handleSubmit}>
-					<TextField className="form-field" id="asset-id"     name="asset-id"     label="Asset ID"     variant="outlined"/>
+					<TextField className="form-field" id="asset-id"     name="asset-id"     label="Asset ID (required)"     variant="outlined"/>
+					<TextField className="form-field" id="display-name" name="display-name" label="Display Name" variant="outlined"/>
+					<TextField className="form-field" id="creator"      name="creator"      label="Creator"      variant="outlined"/>
+					<GameSelection game={game} setGame={setGame} />
 					<span className="spacer form-spacer"></span>
 					<Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{
 						width: "400px",