diff --git a/Cargo.lock b/Cargo.lock
index 7edf745e55..58d713077a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5526,6 +5526,7 @@ dependencies = [
 name = "veloren-client"
 version = "0.8.0"
 dependencies = [
+ "async-channel",
  "authc",
  "byteorder",
  "clap",
@@ -5541,8 +5542,6 @@ dependencies = [
  "termcolor",
  "tokio",
  "tracing",
- "tracing-appender",
- "tracing-log",
  "tracing-subscriber",
  "vek 0.14.1",
  "veloren-common",
diff --git a/client/Cargo.toml b/client/Cargo.toml
index 36feda5f8f..48d369472f 100644
--- a/client/Cargo.toml
+++ b/client/Cargo.toml
@@ -7,7 +7,7 @@ edition = "2018"
 [features]
 simd = ["vek/platform_intrinsics"]
 plugins = ["common-sys/plugins"]
-bin_bot = ["common-ecs", "serde", "ron", "clap", "rustyline", "termcolor", "tracing-appender", "tracing-log", "tracing-subscriber"]
+bin_bot = ["common-ecs", "serde", "ron", "clap", "rustyline", "termcolor", "tracing-subscriber", "async-channel"]
 
 default = ["simd"]
 
@@ -31,6 +31,7 @@ hashbrown = { version = "0.9", features = ["rayon", "serde", "nightly"] }
 authc = { git = "https://gitlab.com/veloren/auth.git", rev = "fb3dcbc4962b367253f8f2f92760ef44d2679c9a" }
 
 #bot only
+async-channel = { version = "1.6", optional = true }
 common-ecs = { package = "veloren-common-ecs", path = "../common/ecs", optional = true }
 serde = { version = "1.0", features = [ "rc", "derive" ], optional = true }
 ron = { version = "0.6", default-features = false, optional = true }
@@ -38,8 +39,6 @@ clap = { version = "2.33", optional = true }
 rustyline = { version = "8.0.0", optional = true }
 ## logging
 termcolor = { version = "1.1", optional = true }
-tracing-appender = { version = "0.1", optional = true }
-tracing-log = { version = "0.1.1", optional = true }
 tracing-subscriber = {version = "0.2.3", default-features = false, features = ["env-filter", "fmt", "chrono", "ansi", "smallvec", "tracing-log"], optional = true }
 
 [dev-dependencies]
diff --git a/client/src/bin/bot/main.rs b/client/src/bin/bot/main.rs
index ee11c8c631..5882a03ba6 100644
--- a/client/src/bin/bot/main.rs
+++ b/client/src/bin/bot/main.rs
@@ -3,33 +3,19 @@
 #[macro_use] extern crate serde;
 
 use authc::AuthClient;
-use clap::{App, AppSettings, Arg, SubCommand};
 use common::{clock::Clock, comp};
 use hashbrown::HashMap;
 use serde::{Deserialize, Serialize};
 use std::{sync::Arc, time::Duration};
 use tokio::runtime::Runtime;
-use tracing::{error, info, warn};
+use tracing::{info, trace, warn};
 use veloren_client::{addr::ConnectionArgs, Client};
 
 mod settings;
+mod tui;
 
 use settings::Settings;
-
-pub fn init_logging() {
-    use termcolor::{ColorChoice, StandardStream};
-    use tracing::Level;
-    use tracing_subscriber::{filter::LevelFilter, EnvFilter, FmtSubscriber};
-    const RUST_LOG_ENV: &str = "RUST_LOG";
-    let filter = EnvFilter::from_env(RUST_LOG_ENV).add_directive(LevelFilter::INFO.into());
-    let subscriber = FmtSubscriber::builder()
-        .with_max_level(Level::ERROR)
-        .with_env_filter(filter);
-
-    subscriber
-        .with_writer(|| StandardStream::stdout(ColorChoice::Auto))
-        .init();
-}
+use tui::Cmd;
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct BotCreds {
@@ -38,18 +24,28 @@ pub struct BotCreds {
 }
 
 pub fn main() {
-    init_logging();
+    tui::init_logging();
 
     let settings = Settings::load();
     info!("Settings: {:?}", settings);
 
+    let (_tui, cmds) = tui::Tui::new();
     let mut bc = BotClient::new(settings);
-    bc.repl();
+    'outer: loop {
+        loop {
+            match cmds.try_recv() {
+                Ok(cmd) => bc.cmd(cmd),
+                Err(async_channel::TryRecvError::Empty) => break,
+                Err(async_channel::TryRecvError::Closed) => break 'outer,
+            }
+        }
+        bc.tick();
+    }
+    info!("shutdown complete");
 }
 
 pub struct BotClient {
     settings: Settings,
-    readline: rustyline::Editor<()>,
     runtime: Arc<Runtime>,
     menu_client: Client,
     bot_clients: HashMap<String, Client>,
@@ -71,13 +67,11 @@ pub fn make_client(runtime: &Arc<Runtime>, server: &str) -> Client {
 
 impl BotClient {
     pub fn new(settings: Settings) -> BotClient {
-        let readline = rustyline::Editor::<()>::new();
         let runtime = Arc::new(Runtime::new().unwrap());
         let menu_client: Client = make_client(&runtime, &settings.server);
         let clock = Clock::new(Duration::from_secs_f64(1.0 / 60.0));
         BotClient {
             settings,
-            readline,
             runtime,
             menu_client,
             bot_clients: HashMap::new(),
@@ -85,75 +79,38 @@ impl BotClient {
         }
     }
 
-    pub fn repl(&mut self) {
-        loop {
-            match self.readline.readline("\n\nbotclient> ") {
-                Ok(cmd) => {
-                    let keep_going = self.process_command(&cmd);
-                    self.readline.add_history_entry(cmd);
-                    if !keep_going {
-                        break;
-                    }
-                },
-                Err(_) => break,
-            }
+    pub fn tick(&mut self) {
+        self.clock.tick();
+        for (username, client) in self.bot_clients.iter_mut() {
+            //trace!("cl {:?}: {:?}", username, client.character_list());
+            trace!(?username, "tick");
+            let msgs: Result<Vec<veloren_client::Event>, veloren_client::Error> =
+                client.tick(comp::ControllerInputs::default(), self.clock.dt(), |_| {});
+            /*trace!(
+                "msgs {:?}: {:?} {:?}",
+                username,
+                msgs,
+                client.character_list()
+            );*/
         }
     }
 
-    pub fn process_command(&mut self, cmd: &str) -> bool {
-        let matches = App::new("veloren-botclient")
-            .version(common::util::DISPLAY_VERSION_LONG.as_str())
-            .author("The veloren devs <https://gitlab.com/veloren/veloren>")
-            .about("The veloren bot client allows logging in as a horde of bots for load-testing")
-            .setting(AppSettings::NoBinaryName)
-            .subcommand(
-                SubCommand::with_name("register")
-                    .about("Register more bots with the auth server")
-                    .args(&[
-                        Arg::with_name("prefix").required(true),
-                        Arg::with_name("password").required(true),
-                        Arg::with_name("count"),
-                    ]),
-            )
-            .subcommand(
-                SubCommand::with_name("login")
-                    .about("Login all registered bots whose username starts with a prefix")
-                    .args(&[Arg::with_name("prefix").required(true)]),
-            )
-            .subcommand(SubCommand::with_name("tick").about("Handle ticks for all logged in bots"))
-            .get_matches_from_safe(cmd.split(" "));
-        use clap::ErrorKind::*;
-        match matches {
-            Ok(matches) => match matches.subcommand() {
-                ("register", Some(matches)) => self.handle_register(
-                    matches.value_of("prefix").unwrap(),
-                    matches.value_of("password").unwrap(),
-                    matches
-                        .value_of("count")
-                        .and_then(|x| x.parse::<usize>().ok()),
-                ),
-                ("login", Some(matches)) => self.handle_login(matches.value_of("prefix").unwrap()),
-                ("tick", _) => self.handle_tick(),
-                _ => {},
-            },
-            Err(e)
-                if [HelpDisplayed, MissingRequiredArgument, UnknownArgument].contains(&e.kind) =>
-            {
-                println!("{}", e.message);
-            }
-            Err(e) => {
-                error!("{:?}", e);
-                return false;
-            },
+    pub fn cmd(&mut self, cmd: Cmd) {
+        match cmd {
+            Cmd::Register {
+                prefix,
+                password,
+                count,
+            } => self.handle_register(&prefix, &password, count),
+            Cmd::Login { prefix } => self.handle_login(&prefix),
         }
-        true
     }
 
     pub fn handle_register(&mut self, prefix: &str, password: &str, count: Option<usize>) {
         let usernames = match count {
             Some(n) => (0..n)
                 .into_iter()
-                .map(|i| format!("{}{}", prefix, i))
+                .map(|i| format!("{}{:03}", prefix, i))
                 .collect::<Vec<String>>(),
             None => vec![prefix.to_string()],
         };
@@ -194,14 +151,7 @@ impl BotClient {
         } else {
             warn!("Server's auth_provider is None");
         }
-    }
-
-    pub fn client_for_bot(&mut self, username: &str) -> &mut Client {
-        let runtime = Arc::clone(&self.runtime);
-        let server = self.settings.server.clone();
-        self.bot_clients
-            .entry(username.to_string())
-            .or_insert_with(|| make_client(&runtime, &server))
+        info!("register done");
     }
 
     pub fn handle_login(&mut self, prefix: &str) {
@@ -214,7 +164,13 @@ impl BotClient {
             .collect();
         for cred in creds.iter() {
             let runtime = Arc::clone(&self.runtime);
-            let client = self.client_for_bot(&cred.username);
+
+            let server = self.settings.server.clone();
+            let client = self
+                .bot_clients
+                .entry(cred.username.clone())
+                .or_insert_with(|| make_client(&runtime, &server));
+
             // TODO: log the clients in in parallel instead of in series
             if let Err(e) = runtime.block_on(client.register(
                 cred.username.clone(),
@@ -246,21 +202,6 @@ impl BotClient {
             //client.create_character(cred.username.clone(),
             // Some("common.items.debug.admin_stick".to_string()), body.into());
         }
-    }
-
-    // TODO: maybe do this automatically in a threadpool instead of as a command
-    pub fn handle_tick(&mut self) {
-        self.clock.tick();
-        for (username, client) in self.bot_clients.iter_mut() {
-            info!("cl {:?}: {:?}", username, client.character_list());
-            let msgs: Result<Vec<veloren_client::Event>, veloren_client::Error> =
-                client.tick(comp::ControllerInputs::default(), self.clock.dt(), |_| {});
-            info!(
-                "msgs {:?}: {:?} {:?}",
-                username,
-                msgs,
-                client.character_list()
-            );
-        }
+        info!("login done");
     }
 }
diff --git a/client/src/bin/bot/tui.rs b/client/src/bin/bot/tui.rs
new file mode 100644
index 0000000000..98ed6c4102
--- /dev/null
+++ b/client/src/bin/bot/tui.rs
@@ -0,0 +1,114 @@
+use clap::{App, AppSettings, Arg, SubCommand};
+use std::{thread, time::Duration};
+use tracing::error;
+
+pub fn init_logging() {
+    use termcolor::{ColorChoice, StandardStream};
+    use tracing::Level;
+    use tracing_subscriber::{filter::LevelFilter, EnvFilter, FmtSubscriber};
+    const RUST_LOG_ENV: &str = "RUST_LOG";
+    let filter = EnvFilter::from_env(RUST_LOG_ENV).add_directive(LevelFilter::INFO.into());
+    let subscriber = FmtSubscriber::builder()
+        .with_max_level(Level::ERROR)
+        .with_env_filter(filter);
+
+    subscriber
+        .with_writer(|| StandardStream::stdout(ColorChoice::Auto))
+        .init();
+}
+
+pub enum Cmd {
+    Register {
+        prefix: String,
+        password: String,
+        count: Option<usize>,
+    },
+    Login {
+        prefix: String,
+    },
+}
+
+pub struct Tui {
+    _handle: thread::JoinHandle<()>,
+}
+
+impl Tui {
+    pub fn new() -> (Self, async_channel::Receiver<Cmd>) {
+        let (mut commands_s, commands_r) = async_channel::unbounded();
+
+        let handle = thread::spawn(move || {
+            thread::sleep(Duration::from_millis(20));
+            let mut readline = rustyline::Editor::<()>::new();
+            loop {
+                match readline.readline("\n\nbotclient> ") {
+                    Ok(cmd) => {
+                        let keep_going = Self::process_command(&cmd, &mut commands_s);
+                        readline.add_history_entry(cmd);
+                        if !keep_going {
+                            break;
+                        }
+                    },
+                    Err(_) => break,
+                }
+            }
+        });
+
+        (Self { _handle: handle }, commands_r)
+    }
+
+    pub fn process_command(cmd: &str, command_s: &mut async_channel::Sender<Cmd>) -> bool {
+        let matches = App::new("veloren-botclient")
+            .version(common::util::DISPLAY_VERSION_LONG.as_str())
+            .author("The veloren devs <https://gitlab.com/veloren/veloren>")
+            .about("The veloren bot client allows logging in as a horde of bots for load-testing")
+            .setting(AppSettings::NoBinaryName)
+            .subcommand(
+                SubCommand::with_name("register")
+                    .about("Register more bots with the auth server")
+                    .args(&[
+                        Arg::with_name("prefix").required(true),
+                        Arg::with_name("password").required(true),
+                        Arg::with_name("count"),
+                    ]),
+            )
+            .subcommand(
+                SubCommand::with_name("login")
+                    .about("Login all registered bots whose username starts with a prefix")
+                    .args(&[Arg::with_name("prefix").required(true)]),
+            )
+            .subcommand(SubCommand::with_name("tick").about("Handle ticks for all logged in bots"))
+            .get_matches_from_safe(cmd.split(" "));
+        use clap::ErrorKind::*;
+        match matches {
+            Ok(matches) => {
+                if match matches.subcommand() {
+                    ("register", Some(matches)) => command_s.try_send(Cmd::Register {
+                        prefix: matches.value_of("prefix").unwrap().to_string(),
+                        password: matches.value_of("password").unwrap().to_string(),
+                        count: matches
+                            .value_of("count")
+                            .and_then(|x| x.parse::<usize>().ok()),
+                    }),
+                    ("login", Some(matches)) => command_s.try_send(Cmd::Login {
+                        prefix: matches.value_of("prefix").unwrap().to_string(),
+                    }),
+                    _ => Ok(()),
+                }
+                .is_err()
+                {
+                    return false;
+                }
+            },
+            Err(e)
+                if [HelpDisplayed, MissingRequiredArgument, UnknownArgument].contains(&e.kind) =>
+            {
+                println!("{}", e.message);
+            }
+            Err(e) => {
+                error!("{:?}", e);
+                return false;
+            },
+        }
+        true
+    }
+}