diff --git a/strafe-client/src/file.rs b/strafe-client/src/file.rs
index 3adc397..141a83c 100644
--- a/strafe-client/src/file.rs
+++ b/strafe-client/src/file.rs
@@ -10,6 +10,8 @@ pub enum ReadError{
 	StrafesNET(strafesnet_snf::Error),
 	#[cfg(feature="snf")]
 	StrafesNETMap(strafesnet_snf::map::Error),
+	#[cfg(feature="snf")]
+	StrafesNETBot(strafesnet_snf::bot::Error),
 	Io(std::io::Error),
 	UnknownFileFormat,
 }
@@ -20,28 +22,31 @@ impl std::fmt::Display for ReadError{
 }
 impl std::error::Error for ReadError{}
 
-pub enum DataStructure{
+enum Format{
 	#[cfg(feature="roblox")]
 	Roblox(strafesnet_rbx_loader::Model),
 	#[cfg(feature="source")]
 	Source(strafesnet_bsp_loader::Bsp),
 	#[cfg(feature="snf")]
-	StrafesNET(strafesnet_common::map::CompleteMap),
+	SNFM(strafesnet_common::map::CompleteMap),
+	#[cfg(feature="snf")]
+	SNFB(strafesnet_snf::bot::Segment),
 }
 
-pub fn read<R:Read+std::io::Seek>(input:R)->Result<DataStructure,ReadError>{
+pub fn read<R:Read+std::io::Seek>(input:R)->Result<Format,ReadError>{
 	let mut buf=std::io::BufReader::new(input);
 	let peek=std::io::BufRead::fill_buf(&mut buf).map_err(ReadError::Io)?;
 	match &peek[0..4]{
 		#[cfg(feature="roblox")]
-		b"<rob"=>Ok(DataStructure::Roblox(strafesnet_rbx_loader::read(buf).map_err(ReadError::Roblox)?)),
+		b"<rob"=>Ok(Format::Roblox(strafesnet_rbx_loader::read(buf).map_err(ReadError::Roblox)?)),
 		#[cfg(feature="source")]
-		b"VBSP"=>Ok(DataStructure::Source(strafesnet_bsp_loader::read(buf).map_err(ReadError::Source)?)),
+		b"VBSP"=>Ok(Format::Source(strafesnet_bsp_loader::read(buf).map_err(ReadError::Source)?)),
 		#[cfg(feature="snf")]
-		b"SNFM"=>Ok(DataStructure::StrafesNET(
-			strafesnet_snf::read_map(buf).map_err(ReadError::StrafesNET)?
-			.into_complete_map().map_err(ReadError::StrafesNETMap)?
-		)),
+		b"SNFM"=>Ok(match strafesnet_snf::read_snf(buf).map_err(ReadError::StrafesNET)?{
+			strafesnet_snf::SNF::Map(streamable_map)=>Format::SNFM(streamable_map.into_complete_map().map_err(ReadError::StrafesNETMap)?),
+			strafesnet_snf::SNF::Bot(mut streamable_bot)=>Format::SNFB(streamable_bot.read_all().map_err(ReadError::StrafesNETBot)?),
+			strafesnet_snf::SNF::Demo(_streamable_demo)=>Err(ReadError::UnknownFileFormat)?,
+		}),
 		_=>Err(ReadError::UnknownFileFormat),
 	}
 }
@@ -59,14 +64,23 @@ impl std::fmt::Display for LoadError{
 }
 impl std::error::Error for LoadError{}
 
-pub fn load<P:AsRef<std::path::Path>>(path:P)->Result<strafesnet_common::map::CompleteMap,LoadError>{
+pub enum Format2{
+	#[cfg(feature="snf")]
+	Map(strafesnet_common::map::CompleteMap),
+	#[cfg(feature="snf")]
+	Bot(strafesnet_snf::bot::Segment),
+}
+
+pub fn load<P:AsRef<std::path::Path>>(path:P)->Result<Format2,LoadError>{
 	//blocking because it's simpler...
 	let file=std::fs::File::open(path).map_err(LoadError::File)?;
 	match read(file).map_err(LoadError::ReadError)?{
 		#[cfg(feature="snf")]
-		DataStructure::StrafesNET(map)=>Ok(map),
+		Format::SNFB(bot)=>Ok(Format2::Bot(bot)),
+		#[cfg(feature="snf")]
+		Format::SNFM(map)=>Ok(Format2::Map(map)),
 		#[cfg(feature="roblox")]
-		DataStructure::Roblox(model)=>{
+		Format::Roblox(model)=>{
 			let mut place=model.into_place();
 			place.run_scripts();
 
@@ -99,10 +113,10 @@ pub fn load<P:AsRef<std::path::Path>>(path:P)->Result<strafesnet_common::map::Co
 				)
 			);
 
-			Ok(map)
+			Ok(Format2::Map(map))
 		},
 		#[cfg(feature="source")]
-		DataStructure::Source(bsp)=>{
+		Format::Source(bsp)=>{
 			let mut loader=strafesnet_deferred_loader::source_legacy();
 
 			let (texture_loader,mesh_loader)=loader.get_inner_mut();
@@ -138,7 +152,7 @@ pub fn load<P:AsRef<std::path::Path>>(path:P)->Result<strafesnet_common::map::Co
 				),
 			);
 
-			Ok(map)
+			Ok(Format2::Map(map))
 		},
 	}
 }
diff --git a/strafe-client/src/physics_worker.rs b/strafe-client/src/physics_worker.rs
index 6814d11..40bfaf4 100644
--- a/strafe-client/src/physics_worker.rs
+++ b/strafe-client/src/physics_worker.rs
@@ -15,6 +15,7 @@ pub enum Instruction{
 	Render,
 	Resize(winit::dpi::PhysicalSize<u32>),
 	ChangeMap(strafesnet_common::map::CompleteMap),
+	LoadReplay(strafesnet_snf::bot::Segment),
 }
 
 pub fn new<'a>(
@@ -69,6 +70,9 @@ pub fn new<'a>(
 				run_session_instruction!(ins.time,SessionInstruction::Input(SessionInputInstruction::Mode(crate::session::ImplicitModeInstruction::ResetAndSpawn(strafesnet_common::gameplay_modes::ModeId::MAIN,strafesnet_common::gameplay_modes::StageId::FIRST))));
 				run_graphics_worker_instruction!(GraphicsInstruction::ChangeMap(complete_map));
 			},
+			Instruction::LoadReplay(bot)=>{
+				run_session_instruction!(ins.time,SessionInstruction::LoadReplay(bot));
+			}
 		}
 	})
 }
diff --git a/strafe-client/src/session.rs b/strafe-client/src/session.rs
index 4503aa1..b5b8ba3 100644
--- a/strafe-client/src/session.rs
+++ b/strafe-client/src/session.rs
@@ -22,6 +22,7 @@ pub enum Instruction<'a>{
 	Control(SessionControlInstruction),
 	Playback(SessionPlaybackInstruction),
 	ChangeMap(&'a strafesnet_common::map::CompleteMap),
+	LoadReplay(strafesnet_snf::bot::Segment),
 	Idle,
 }
 
@@ -44,6 +45,7 @@ pub enum SessionControlInstruction{
 	// copy the current session simulation recording into a replay and view it
 	CopyRecordingIntoReplayAndSpectate,
 	StopSpectate,
+	SaveReplay,
 }
 pub enum SessionPlaybackInstruction{
 	SkipForward,
@@ -87,6 +89,11 @@ pub struct Recording{
 	instructions:Vec<TimedInstruction<PhysicsInputInstruction,PhysicsTimeInner>>,
 }
 impl Recording{
+	fn new(
+		instructions:Vec<TimedInstruction<PhysicsInputInstruction,PhysicsTimeInner>>,
+	)->Self{
+		Self{instructions}
+	}
 	fn clear(&mut self){
 		self.instructions.clear();
 	}
@@ -281,6 +288,16 @@ impl InstructionConsumer<Instruction<'_>> for Session{
 				}
 				_=self.simulation.timer.set_paused(ins.time,false);
 			},
+			Instruction::Control(SessionControlInstruction::SaveReplay)=>{
+				let view_state=core::mem::replace(&mut self.view_state,ViewState::Play);
+				let file=std::fs::File::create(format!("{}.snfb",ins.time)).unwrap();
+				match view_state{
+					ViewState::Play=>(),
+					ViewState::Replay(bot_id)=>if let Some(replay)=self.replays.remove(&bot_id){
+						strafesnet_snf::bot::write_bot(std::io::BufWriter::new(file),replay.recording.instructions).unwrap();
+					},
+				}
+			},
 			Instruction::Playback(SessionPlaybackInstruction::IncreaseTimescale)=>{
 				match &self.view_state{
 					ViewState::Play=>{
@@ -339,6 +356,30 @@ impl InstructionConsumer<Instruction<'_>> for Session{
 				self.clear_recording();
 				self.change_map(complete_map);
 			},
+			Instruction::LoadReplay(bot)=>{
+				// pause simulation
+				_=self.simulation.timer.set_paused(ins.time,true);
+
+				// create recording
+				let recording=Recording::new(bot.instructions);
+
+				// create timer starting at first instruction (or zero if the list is empty)
+				let new_time=recording.instructions.first().map_or(PhysicsTime::ZERO,|ins|ins.time);
+				let timer=Timer::unpaused(ins.time,new_time);
+
+				// create default physics state
+				let simulation=Simulation::new(timer,Default::default());
+
+				// invent a new bot id and insert the replay
+				let bot_id=BotId(self.replays.len() as u32);
+				self.replays.insert(bot_id,Replay::new(
+					recording,
+					simulation,
+				));
+
+				// begin spectate
+				self.view_state=ViewState::Replay(bot_id);
+			},
 			Instruction::Idle=>{
 				run_mouse_interpolator_instruction!(MouseInterpolatorInstruction::Idle);
 				// this just refreshes the replays
diff --git a/strafe-client/src/setup.rs b/strafe-client/src/setup.rs
index 5049881..acb51b3 100644
--- a/strafe-client/src/setup.rs
+++ b/strafe-client/src/setup.rs
@@ -215,7 +215,7 @@ pub fn setup_and_start(title:&str){
 		setup_context,
 	);
 
-	if let Some(arg)=std::env::args().nth(1){
+	for arg in std::env::args().skip(1){
 		let path=std::path::PathBuf::from(arg);
 		window_thread.send(TimedInstruction{
 			time:integer::Time::ZERO,
diff --git a/strafe-client/src/window.rs b/strafe-client/src/window.rs
index 00f52a2..c1b5a96 100644
--- a/strafe-client/src/window.rs
+++ b/strafe-client/src/window.rs
@@ -1,6 +1,7 @@
 use strafesnet_common::instruction::TimedInstruction;
 use strafesnet_common::session::{Time as SessionTime,TimeInner as SessionTimeInner};
 use strafesnet_common::physics::{MiscInstruction,SetControlInstruction};
+use crate::file::Format2;
 use crate::physics_worker::Instruction as PhysicsWorkerInstruction;
 use crate::session::{SessionInputInstruction,SessionControlInstruction,SessionPlaybackInstruction};
 
@@ -29,7 +30,8 @@ impl WindowContext<'_>{
 		match event{
 			winit::event::WindowEvent::DroppedFile(path)=>{
 				match crate::file::load(path.as_path()){
-					Ok(map)=>self.physics_thread.send(TimedInstruction{time,instruction:PhysicsWorkerInstruction::ChangeMap(map)}).unwrap(),
+					Ok(Format2::Map(map))=>self.physics_thread.send(TimedInstruction{time,instruction:PhysicsWorkerInstruction::ChangeMap(map)}).unwrap(),
+					Ok(Format2::Bot(bot))=>self.physics_thread.send(TimedInstruction{time,instruction:PhysicsWorkerInstruction::LoadReplay(bot)}).unwrap(),
 					Err(e)=>println!("Failed to load map: {e}"),
 				}
 			},
@@ -153,6 +155,7 @@ impl WindowContext<'_>{
 								"F"|"f"=>input_misc!(PracticeFly,s),
 								"B"|"b"=>session_ctrl!(CopyRecordingIntoReplayAndSpectate,s),
 								"X"|"x"=>session_ctrl!(StopSpectate,s),
+								"N"|"n"=>session_ctrl!(SaveReplay,s),
 								_=>None,
 							},
 							_=>None,