diff --git a/server/src/cmd.rs b/server/src/cmd.rs
index 2b9e1530e4..960044b230 100644
--- a/server/src/cmd.rs
+++ b/server/src/cmd.rs
@@ -6,6 +6,7 @@ use crate::{
     settings::{
         Ban, BanAction, BanInfo, EditableSetting, SettingError, WhitelistInfo, WhitelistRecord,
     },
+    sys::terrain::NpcData,
     wiring::{Logic, OutputFormula},
     Server, SpawnPoint, StateExt,
 };
@@ -28,6 +29,7 @@ use common::{
     depot,
     effect::Effect,
     event::{EventBus, ServerEvent},
+    generation::EntityInfo,
     npc::{self, get_npc_name},
     resources::{PlayerPhysicsSettings, TimeOfDay},
     terrain::{Block, BlockKind, SpriteKind, TerrainChunkSize},
@@ -550,13 +552,90 @@ fn handle_make_block(
 }
 
 fn handle_make_npc(
-    _server: &mut Server,
-    _client: EcsEntity,
-    _target: EcsEntity,
-    _args: Vec<String>,
-    _action: &ChatCommand,
+    server: &mut Server,
+    client: EcsEntity,
+    target: EcsEntity,
+    args: Vec<String>,
+    action: &ChatCommand,
 ) -> CmdResult<()> {
-    Err("Not implemented".to_owned())
+    let (entity_config, number) = parse_args!(args, String, i8);
+
+    let entity_config = entity_config.ok_or_else(|| action.help_string())?;
+    let number = match number {
+        Some(i8::MIN..=0) => {
+            return Err("Number of entities should be at least 1".to_owned());
+        },
+        Some(50..=i8::MAX) => {
+            return Err("Number of entities should be less than 50".to_owned());
+        },
+        Some(number) => number,
+        None => 1,
+    };
+
+    let rng = &mut rand::thread_rng();
+    for _ in 0..number {
+        let comp::Pos(pos) = position(server, target, "target")?;
+        let entity_info = EntityInfo::at(pos).with_asset_expect(&entity_config);
+        match NpcData::from_entity_info(entity_info, rng) {
+            NpcData::Waypoint(_) => {
+                return Err("Waypoint spawning is not implemented".to_owned());
+            },
+            NpcData::Data {
+                loadout,
+                pos,
+                stats,
+                skill_set,
+                poise,
+                health,
+                body,
+                agent,
+                alignment,
+                scale,
+                drop_item,
+            } => {
+                let inventory = Inventory::new_with_loadout(loadout);
+
+                let mut entity_builder = server
+                    .state
+                    .create_npc(pos, stats, skill_set, health, poise, inventory, body)
+                    .with(alignment)
+                    .with(scale)
+                    .with(comp::Vel(Vec3::new(0.0, 0.0, 0.0)))
+                    .with(comp::MountState::Unmounted);
+
+                if let Some(agent) = agent {
+                    entity_builder = entity_builder.with(agent);
+                }
+
+                if let Some(drop_item) = drop_item {
+                    entity_builder = entity_builder.with(comp::ItemDrop(drop_item));
+                }
+
+                // Some would say it's a hack, some would say it's incomplete
+                // simulation. But this is what we do to avoid PvP between npc.
+                use comp::Alignment;
+                let npc_group = match alignment {
+                    Alignment::Enemy => Some(comp::group::ENEMY),
+                    Alignment::Npc | Alignment::Tame => Some(comp::group::NPC),
+                    Alignment::Wild | Alignment::Passive | Alignment::Owned(_) => None,
+                };
+                if let Some(group) = npc_group {
+                    entity_builder = entity_builder.with(group);
+                }
+                entity_builder.build();
+            },
+        };
+    }
+
+    server.notify_client(
+        client,
+        ServerGeneral::server_msg(
+            ChatType::CommandInfo,
+            format!("Spawned {} entities from config: {}", number, entity_config),
+        ),
+    );
+
+    Ok(())
 }
 
 fn handle_make_sprite(