diff --git a/common/src/cmd.rs b/common/src/cmd.rs
index a1692dbabf..e2868f0f10 100644
--- a/common/src/cmd.rs
+++ b/common/src/cmd.rs
@@ -80,6 +80,7 @@ pub enum ChatCommand {
     Safezone,
     Say,
     SetMotd,
+    Site,
     SkillPoint,
     Spawn,
     Sudo,
@@ -140,6 +141,7 @@ pub static CHAT_COMMANDS: &[ChatCommand] = &[
     ChatCommand::Safezone,
     ChatCommand::Say,
     ChatCommand::SetMotd,
+    ChatCommand::Site,
     ChatCommand::SkillPoint,
     ChatCommand::Spawn,
     ChatCommand::Sudo,
@@ -438,6 +440,9 @@ impl ChatCommand {
             ChatCommand::SetMotd => {
                 cmd(vec![Message(Optional)], "Set the server description", Admin)
             },
+            // Uses Message because site names can contain spaces, which would be assumed to be
+            // separators otherwise
+            ChatCommand::Site => cmd(vec![Message(Required)], "Teleport to a site", Admin),
             ChatCommand::SkillPoint => cmd(
                 vec![
                     Enum("skill tree", SKILL_TREES.clone(), Required),
@@ -546,6 +551,7 @@ impl ChatCommand {
             ChatCommand::Safezone => "safezone",
             ChatCommand::Say => "say",
             ChatCommand::SetMotd => "set_motd",
+            ChatCommand::Site => "site",
             ChatCommand::SkillPoint => "skill_point",
             ChatCommand::Spawn => "spawn",
             ChatCommand::Sudo => "sudo",
diff --git a/server/src/cmd.rs b/server/src/cmd.rs
index 90796fce26..61a49ca553 100644
--- a/server/src/cmd.rs
+++ b/server/src/cmd.rs
@@ -127,6 +127,7 @@ fn get_handler(cmd: &ChatCommand) -> CommandHandler {
         ChatCommand::Safezone => handle_safezone,
         ChatCommand::Say => handle_say,
         ChatCommand::SetMotd => handle_set_motd,
+        ChatCommand::Site => handle_site,
         ChatCommand::SkillPoint => handle_skill_point,
         ChatCommand::Spawn => handle_spawn,
         ChatCommand::Sudo => handle_sudo,
@@ -463,6 +464,91 @@ fn handle_goto(
     }
 }
 
+fn handle_site(
+    server: &mut Server,
+    client: EcsEntity,
+    target: EcsEntity,
+    args: String,
+    action: &ChatCommand,
+) {
+    if let Ok(dest_name) = scan_fmt!(&args, &action.arg_fmt(), String) {
+        if server
+            .state
+            .read_component_copied::<comp::Pos>(target)
+            .is_some()
+        {
+            match server.world.civs().sites().find(|site| {
+                site.site_tmp
+                    .map_or(false, |id| server.index.sites[id].name() == dest_name)
+            }) {
+                Some(site) => {
+                    // The bulk of this code is to find the z coordinate to teleport to, searching
+                    // for the lowest available one. Copied nearly verbatim from server's lib.rs
+                    let dest_chunk = site.center;
+                    // Unwrapping because generate_chunk only returns err when should_continue evals
+                    // to true
+                    let (tc, _cs) = server
+                        .world
+                        .generate_chunk(server.index.as_index_ref(), dest_chunk, || false)
+                        .unwrap();
+                    let min_z = tc.get_min_z();
+                    let max_z = tc.get_max_z();
+
+                    let pos = TerrainChunkSize::center_wpos(dest_chunk);
+                    let pos = Vec3::new(pos.x, pos.y, min_z);
+                    let pos = {
+                        use common::vol::ReadVol;
+                        (0..(max_z - min_z))
+                            .map(|z_diff| pos + Vec3::unit_z() * z_diff)
+                            .find(|test_pos| {
+                                let chunk_relative_xy =
+                                    test_pos.xy().map2(TerrainChunkSize::RECT_SIZE, |e, sz| {
+                                        e.rem_euclid(sz as i32)
+                                    });
+                                tc.get(
+                                    Vec3::new(chunk_relative_xy.x, chunk_relative_xy.y, test_pos.z)
+                                        - Vec3::unit_z(),
+                                )
+                                .map_or(false, |b| b.is_filled())
+                                    && (0..3).all(|z| {
+                                        tc.get(
+                                            Vec3::new(
+                                                chunk_relative_xy.x,
+                                                chunk_relative_xy.y,
+                                                test_pos.z,
+                                            ) + Vec3::unit_z() * z,
+                                        )
+                                        .map_or(true, |b| !b.is_solid())
+                                    })
+                            })
+                            .unwrap_or(pos)
+                            .map(|e| e as f32)
+                            + 0.5
+                    };
+                    server.state.write_component(target, comp::Pos(pos));
+                    server.state.write_component(target, comp::ForceUpdate);
+                },
+                None => {
+                    server.notify_client(
+                        client,
+                        ServerGeneral::server_msg(ChatType::CommandError, "Site not found"),
+                    );
+                },
+            };
+        } else {
+            server.notify_client(
+                client,
+                ServerGeneral::server_msg(ChatType::CommandError, "You have no position."),
+            );
+        }
+    } else {
+        server.notify_client(
+            client,
+            ServerGeneral::server_msg(ChatType::CommandError, action.help_string()),
+        );
+    }
+}
+
 fn handle_home(
     server: &mut Server,
     client: EcsEntity,