diff --git a/.gitattributes b/.gitattributes
index f7fa1f5..5064e20 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,5 @@
+/gradlew        text eol=lf
+
 # These are explicitly windows files and should use crlf
 *.bat           text eol=crlf
 
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b3c24f1..774dc16 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,5 +32,5 @@ jobs:
       uses: actions/upload-artifact@v3.1.1
       with:
         name: brainwine
-        path: build/libs/brainwine.jar
+        path: build/dist/brainwine.jar
         retention-days: 7
diff --git a/.gitignore b/.gitignore
index 3e0742f..8017793 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # Gradle
 .gradle
 build
+!**/src/**/build
 
 # Eclipse
 *.launch
@@ -11,4 +12,4 @@ build
 bin
 
 # Misc
-run
\ No newline at end of file
+run
diff --git a/README.md b/README.md
index 17e54f9..9a0a4e0 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,31 @@
-# Brainwine
-[![build](https://github.com/kuroppoi/brainwine/actions/workflows/build.yml/badge.svg)](https://github.com/kuroppoi/brainwine/actions)
+<h1 align="center">Brainwine</h1>
+<p align="center">
+  <a href="https://github.com/kuroppoi/brainwine/actions"><img src="https://github.com/kuroppoi/brainwine/actions/workflows/build.yml/badge.svg" alt="build"/></a>
+  <a href="https://github.com/kuroppoi/brainwine/releases/latest"><img src="https://img.shields.io/github/v/release/kuroppoi/brainwine?labelColor=30373D&label=Release&logoColor=959DA5&logo=github" alt="release"/></a>
+</p>
 
-Brainwine is a Deepworld private server written in Java, made with user-friendliness and portability in mind.
-Due to the time it will take for this project to be complete (and my inconsistent working on it), brainwine has been prematurely open-sourced
-and is free for all to use.\
-Keep in mind, though, that this server is not finished yet. Expect to encounter bad code, bugs and missing features!\
-Brainwine is currently compatible with the following versions of Deepworld:
-- Steam: `v3.13.1`
+Brainwine is a Deepworld private server written in Java, designed to be portable and easy to use.\
+It's still a work in progress, so keep in mind that it's not yet feature-complete. (A to-do list can be found [here](https://github.com/kuroppoi/brainwine/projects/1).)\
+Brainwine currently supports the following versions of Deepworld:
+
+- Windows: `v3.13.1`
 - iOS: `v2.11.0.1`
 - MacOS: `v2.11.1`
 
-## Features
-A list of all planned, in-progress and finished features can be found [here.](https://github.com/kuroppoi/brainwine/projects/1)
+## Quick Local Setup
 
-## Setup
+- Install [Java 8](https://adoptium.net/temurin/releases/?package=jdk&version=8).
+- Download the [latest Brainwine release](https://github.com/kuroppoi/brainwine/releases/latest).
+- Run Brainwine, go to the server tab and start the server.
+- Go to the game tab and start the game.
+  - If this isn't available for you, download a [patching kit](https://github.com/kuroppoi/brainwine/releases/tag/patching-kits-1.0) for your platform and follow the instructions there.
+- Register a new account and play the game.
 
-### Setting up the client
-
-Before you can connect to a server, a few modifications need to be made to the Deepworld game client.\
-The exact process of this differs per platform.\
-You may download an installation package for your desired platform [here.](https://github.com/kuroppoi/brainwine/releases/tag/patching-kits-1.0)
-
-### Setting up the server
+## Building
 
 #### Prerequisites
 
-- Java 8 or newer
-
-You can download the latest release [here.](https://github.com/kuroppoi/brainwine/releases/latest)\
-Alternatively, if you wish to build from source, clone this repository with the `--recurse-submodules` flag\
-and run `gradlew dist` in the root directory of the repository.\
-After the build has finished, the output jar will be located in `build/libs`.\
-You may then start the server through the gui, or start it directly by running the jar with the `disablegui` flag.
+- Java 8 Development Kit
 
 #### Using docker
 
@@ -61,19 +55,23 @@ The server configuration files and the world data is saved in a docker volume an
 
 #### Configurations
 
-On first-time startup, configuration files will be generated which you may modify however you like:
-- `api.json` Configuration file for news & API connectivity information.
-- `loottables.json` Configuration file for which loot may be obtained from containers.
-- `spawning.json` Configuration file for entity spawns per biome.
-- `generators` Folder containing configuration files for zone generators.
+```sh
+git clone --recurse-submodules https://github.com/kuroppoi/brainwine.git
+cd brainwine
+./gradlew dist
+```
 
-## Contributions
+The output will be located in the `/build/dist` directory.
 
-Disagree with how I did something? Found a potential error? See some room for improvement? Or just want to add a feature?
-Glad to hear it! Feel free to make a pull request anytime. Just make sure you follow the code style!
-And, apologies in advance for the lack of documentation. Haven't gotten around to do it yet. Sorry!
+## Usage
 
-## Issues
+Execute `brainwine.jar` to start the program. Navigate to the server tab and press the button to start the server.\
+It is also possible to start the server immediately with no user interface:
 
-Found a bug? Before posting an issue, make sure your build is up-to-date and your issue has not already been posted before.
-Provide a detailed explanation of the issue, and how to reproduce it. I'll get to it ASAP!
+```sh
+# This behavior is the default on platforms that do not support Java's Desktop API.
+java -jar brainwine.jar disablegui
+```
+
+To connect to a local or remote server, download a [patching kit](https://github.com/kuroppoi/brainwine/releases/tag/patching-kits-1.0) for your desired platform.\
+Alternatively, Windows users may use the program's user interface to configure the host settings and start the game.
diff --git a/api/build.gradle b/api/build.gradle
index f441ade..84dcbf4 100644
--- a/api/build.gradle
+++ b/api/build.gradle
@@ -8,9 +8,5 @@ repositories {
 
 dependencies {
     implementation 'io.javalin:javalin:4.6.8'
-    implementation project(':shared')
-}
-
-jar {
-    archiveBaseName = 'brainwine-api'
+    implementation project(':brainwine-shared')
 }
diff --git a/api/src/main/java/brainwine/api/DataFetcher.java b/api/src/main/java/brainwine/api/DataFetcher.java
index a72d6ad..1509e9a 100644
--- a/api/src/main/java/brainwine/api/DataFetcher.java
+++ b/api/src/main/java/brainwine/api/DataFetcher.java
@@ -9,6 +9,7 @@ public interface DataFetcher {
     public boolean isPlayerNameTaken(String name);
     public String registerPlayer(String name);
     public String login(String name, String password);
+    public String fetchPlayerName(String name);
     public boolean verifyAuthToken(String name, String token);
     public boolean verifyApiToken(String apiToken);
     public Collection<ZoneInfo> fetchZoneInfo();
diff --git a/api/src/main/java/brainwine/api/DefaultDataFetcher.java b/api/src/main/java/brainwine/api/DefaultDataFetcher.java
index 9c6b42c..8a5b724 100644
--- a/api/src/main/java/brainwine/api/DefaultDataFetcher.java
+++ b/api/src/main/java/brainwine/api/DefaultDataFetcher.java
@@ -23,6 +23,11 @@ public class DefaultDataFetcher implements DataFetcher {
         throw exception;
     }
     
+    @Override
+    public String fetchPlayerName(String name) {
+        throw exception;
+    }
+    
     @Override
     public boolean verifyAuthToken(String name, String token) {
         throw exception;
diff --git a/api/src/main/java/brainwine/api/GatewayService.java b/api/src/main/java/brainwine/api/GatewayService.java
index 2ff42eb..c2927ea 100644
--- a/api/src/main/java/brainwine/api/GatewayService.java
+++ b/api/src/main/java/brainwine/api/GatewayService.java
@@ -111,7 +111,7 @@ public class GatewayService {
             return;
         }
         
-        ctx.json(new ServerConnectInfo(api.getGameServerHost(), name, token));
+        ctx.json(new ServerConnectInfo(api.getGameServerHost(), dataFetcher.fetchPlayerName(name), token));
     }
     
     /**
diff --git a/build-logic/build.gradle b/build-logic/build.gradle
new file mode 100644
index 0000000..513b583
--- /dev/null
+++ b/build-logic/build.gradle
@@ -0,0 +1,28 @@
+plugins {
+    id 'java-gradle-plugin'
+}
+
+sourceSets {
+    boot {
+    
+    }
+    main {
+        compileClasspath += sourceSets.boot.output
+        runtimeClasspath += sourceSets.boot.output
+    }
+}
+
+gradlePlugin {
+    plugins {
+        distributionPlugin {
+            id = "brainwine.distribution"
+            implementationClass = "brainwine.build.DistributionPlugin"
+        }
+    }
+}
+
+jar {
+    from sourceSets.boot.output
+}
+
+version = '1.0.0-SNAPSHOT'
diff --git a/build-logic/src/boot/java/brainwine/bootstrap/Bootstrap.java b/build-logic/src/boot/java/brainwine/bootstrap/Bootstrap.java
new file mode 100644
index 0000000..e2553f4
--- /dev/null
+++ b/build-logic/src/boot/java/brainwine/bootstrap/Bootstrap.java
@@ -0,0 +1,84 @@
+package brainwine.bootstrap;
+
+import static brainwine.bootstrap.Constants.BOOT_CLASS_KEY;
+import static brainwine.bootstrap.Constants.CLASS_PATH_KEY;
+import static brainwine.bootstrap.Constants.LIBRARY_PATH;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.util.Enumeration;
+import java.util.jar.Attributes;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+public class Bootstrap {
+    
+    public static void main(String[] args) {
+        new Bootstrap().run(args);
+    }
+    
+    private void run(String[] args) {
+        Attributes attributes = null;
+        
+        try {
+            Enumeration<URL> resources = getClass().getClassLoader().getResources(JarFile.MANIFEST_NAME);
+            
+            while(resources.hasMoreElements()) {
+                try(InputStream inputStream = resources.nextElement().openStream()) {
+                    Manifest manifest = new Manifest(inputStream);                    
+                    if(getClass().getName().equals(manifest.getMainAttributes().getValue(Attributes.Name.MAIN_CLASS))) {
+                        attributes = manifest.getMainAttributes();
+                        break;
+                    }
+                }
+            }
+        } catch(IOException e) {
+            System.err.println("Could not load manifest file");
+            e.printStackTrace();
+            System.exit(-1);
+        }
+        
+        String[] libraryNames = attributes.getValue(CLASS_PATH_KEY).split(";");
+        URL[] libraryUrls = new URL[libraryNames.length];
+        
+        try {            
+            for(int i = 0; i < libraryNames.length; i++) {
+                String libraryName = libraryNames[i];
+                
+                try(InputStream inputStream = getClass().getResourceAsStream(String.format("/%s/%s", LIBRARY_PATH, libraryName))) {
+                    File outputFile = new File("libraries", libraryName);
+                    libraryUrls[i] = outputFile.toURI().toURL();
+                    
+                    if(outputFile.exists()) {
+                        continue;
+                    }
+                    
+                    outputFile.getParentFile().mkdirs();
+                    Files.copy(inputStream, outputFile.toPath());
+                }
+            }
+        } catch(Exception e) {
+            System.err.println("Could not extract library JARs");
+            e.printStackTrace();
+            System.exit(-1);
+        }
+        
+        URLClassLoader classLoader = new URLClassLoader(libraryUrls, getClass().getClassLoader().getParent());
+        Thread.currentThread().setContextClassLoader(classLoader);
+        
+        try {
+            Class<?> mainClass = Class.forName(attributes.getValue(BOOT_CLASS_KEY), true, classLoader);
+            Method method = mainClass.getMethod("main", String[].class);
+            method.invoke(null, (Object)args);
+        } catch(ReflectiveOperationException e) {
+            System.err.println("Could not invoke entry point");
+            e.printStackTrace();
+            System.exit(-1);
+        }
+    }
+}
diff --git a/build-logic/src/boot/java/brainwine/bootstrap/Constants.java b/build-logic/src/boot/java/brainwine/bootstrap/Constants.java
new file mode 100644
index 0000000..560c195
--- /dev/null
+++ b/build-logic/src/boot/java/brainwine/bootstrap/Constants.java
@@ -0,0 +1,12 @@
+package brainwine.bootstrap;
+
+import java.util.jar.Attributes;
+
+public class Constants {
+    
+    public static final Attributes.Name BOOT_CLASS_KEY = new Attributes.Name("Dist-Boot-Class");
+    public static final Attributes.Name CLASS_PATH_KEY = new Attributes.Name("Dist-Class-Path");
+    public static final String LICENSE_PATH = "META-INF/LICENSE";
+    public static final String LIBRARY_PATH = "META-INF/libraries";
+    public static final Class<?> MAIN_CLASS = Bootstrap.class;
+}
diff --git a/build-logic/src/main/java/brainwine/build/DistributionPlugin.java b/build-logic/src/main/java/brainwine/build/DistributionPlugin.java
new file mode 100644
index 0000000..c1580d9
--- /dev/null
+++ b/build-logic/src/main/java/brainwine/build/DistributionPlugin.java
@@ -0,0 +1,14 @@
+package brainwine.build;
+
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.plugins.JavaPlugin;
+
+public class DistributionPlugin implements Plugin<Project> {
+    
+    @Override
+    public void apply(Project project) {
+        project.getPlugins().apply(JavaPlugin.class);
+        project.getTasks().register("dist", DistributionTask.class, task -> task.dependsOn("build"));
+    }
+}
diff --git a/build-logic/src/main/java/brainwine/build/DistributionTask.java b/build-logic/src/main/java/brainwine/build/DistributionTask.java
new file mode 100644
index 0000000..2851326
--- /dev/null
+++ b/build-logic/src/main/java/brainwine/build/DistributionTask.java
@@ -0,0 +1,128 @@
+package brainwine.build;
+
+import static brainwine.bootstrap.Constants.BOOT_CLASS_KEY;
+import static brainwine.bootstrap.Constants.CLASS_PATH_KEY;
+import static brainwine.bootstrap.Constants.LIBRARY_PATH;
+import static brainwine.bootstrap.Constants.LICENSE_PATH;
+import static brainwine.bootstrap.Constants.MAIN_CLASS;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.artifacts.Configuration;
+import org.gradle.api.file.FileTree;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.initialization.IncludedBuild;
+import org.gradle.api.invocation.Gradle;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.api.tasks.bundling.Jar;
+
+public abstract class DistributionTask extends DefaultTask {
+    
+    private final FileTree bootCodeTree;
+    
+    @Input
+    public abstract Property<String> getMainClass();
+    
+    @Input
+    @Optional
+    public abstract Property<String> getArchiveFileName();
+    
+    @Input
+    @Optional
+    public abstract RegularFileProperty getLicenseFile();
+    
+    @Inject
+    public DistributionTask(Gradle gradle) {
+        IncludedBuild build = gradle.getIncludedBuilds().stream().filter(x -> x.getName().equals("build-logic")).findFirst().get();
+        bootCodeTree = getProject().fileTree(new File(build.getProjectDir(), "build/classes/java/boot"));
+    }
+    
+    @TaskAction
+    public void createDistributionArchive() throws IOException {
+        Configuration config = getProject().getConfigurations().getByName("runtimeClasspath");
+        Jar jarTask = (Jar)getProject().getTasks().getByName("jar");
+        String archiveFileName = getArchiveFileName().getOrElse(jarTask.getArchiveFileName().get());
+        File outputDirectory = new File(getProject().getBuildDir(), "dist");
+        outputDirectory.mkdirs();
+        File outputFile = new File(outputDirectory, archiveFileName);
+        
+        // Fetch libraries
+        List<File> classpath = new ArrayList<>();
+        config.getResolvedConfiguration().getResolvedArtifacts().forEach(artifact -> classpath.add(artifact.getFile()));
+        jarTask.getOutputs().getFiles().forEach(classpath::add);
+        classpath.sort((a, b) -> a.getName().compareTo(b.getName())); // Guarantee file order
+                
+        // Create jar manifest
+        Manifest manifest = new Manifest();
+        manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
+        manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, MAIN_CLASS.getName());
+        manifest.getMainAttributes().put(BOOT_CLASS_KEY, getMainClass().get());
+        manifest.getMainAttributes().put(CLASS_PATH_KEY, String.join(";", classpath.stream().map(File::getName).collect(Collectors.toList())));
+        manifest.getMainAttributes().putValue("Multi-Release", "true");
+        
+        // Create jar file
+        try(JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(outputFile))) {
+            // Add manifest
+            addJarManifest(outputStream, manifest);
+            
+            // Add libraries
+            for(File file : classpath) {
+                addFileToJar(outputStream, file, String.format("%s/%s", LIBRARY_PATH, file.getName()));
+            }
+            
+            // Add boot code            
+            bootCodeTree.visit(details -> {
+                if(!details.isDirectory()) {
+                    try {
+                        addFileToJar(outputStream, details.getFile(), details.getPath());
+                     } catch(IOException e) {
+                         throw new GradleException(e.getMessage(), e);
+                     }
+                }
+            });
+            
+            // Add license
+            RegularFileProperty licenseFile = getLicenseFile();
+            
+            if(licenseFile.isPresent()) {
+                addFileToJar(outputStream, licenseFile.get().getAsFile(), LICENSE_PATH);
+            }
+        }
+    }
+    
+    private void addJarManifest(JarOutputStream outputStream, Manifest manifest) throws IOException {
+        JarEntry entry = new JarEntry(JarFile.MANIFEST_NAME);
+        entry.setTime(0);
+        outputStream.putNextEntry(entry);
+        manifest.write(outputStream);
+        outputStream.closeEntry();
+    }
+    
+    private void addFileToJar(JarOutputStream outputStream, File file, String targetPath) throws IOException {
+        byte[] bytes = Files.readAllBytes(file.toPath());
+        JarEntry entry = new JarEntry(targetPath);
+        entry.setTime(0);
+        entry.setSize(bytes.length);
+        outputStream.putNextEntry(entry);
+        outputStream.write(bytes);
+        outputStream.closeEntry();
+    }
+}
diff --git a/build.gradle b/build.gradle
index fcf038c..c47d584 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,9 +1,9 @@
 plugins {
-    id 'java'
+    id 'brainwine.distribution'
 }
 
 ext {
-    mainClass = 'brainwine.Bootstrap'
+    mainClass = 'brainwine.Main'
     workingDirectory = 'run'
 }
 
@@ -15,26 +15,14 @@ dependencies {
     implementation 'com.formdev:flatlaf-intellij-themes:3.0'
     implementation 'com.formdev:flatlaf-extras:3.0'
     implementation 'com.formdev:flatlaf:3.0'
-    implementation project(':api')
-    implementation project(':gameserver')
-    implementation project(':shared')
+    implementation project(':brainwine-api')
+    implementation project(':brainwine-gameserver')
+    implementation project(':brainwine-shared')
 }
 
-task dist(type: Jar) {
-    manifest {
-        attributes 'Multi-Release': 'true',
-                   'Main-Class': project.ext.mainClass
-    }
-    
-    from {
-        configurations.runtimeClasspath.collect {
-            it.isDirectory() ? it : zipTree(it)
-        }
-    }
-    
-    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
-    dependsOn configurations.runtimeClasspath
-    with jar
+dist {
+    mainClass = project.ext.mainClass
+    licenseFile = file("${project.rootDir}/LICENSE.md")
 }
 
 task run(type: JavaExec) {
diff --git a/deepworld-config b/deepworld-config
index ec2749d..6052c53 160000
--- a/deepworld-config
+++ b/deepworld-config
@@ -1 +1 @@
-Subproject commit ec2749d94b86dbbdefa52e0146ce278688e28370
+Subproject commit 6052c53944e3d4469819e725c59914ea136b2007
diff --git a/gameserver/build.gradle b/gameserver/build.gradle
index 847e487..b707887 100644
--- a/gameserver/build.gradle
+++ b/gameserver/build.gradle
@@ -24,11 +24,7 @@ dependencies {
     implementation 'org.reflections:reflections:0.10.2'
     implementation 'io.netty:netty-all:4.1.79.Final'
     implementation 'org.mindrot:jbcrypt:0.4'
-    implementation project(':shared')
-}
-
-jar {
-    archiveBaseName = 'brainwine-gameserver'
+    implementation project(':brainwine-shared')
 }
 
 processResources.includeEmptyDirs = false
diff --git a/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java
index 60740db..f356533 100644
--- a/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java
+++ b/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java
@@ -24,7 +24,7 @@ import org.yaml.snakeyaml.Yaml;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 
-import brainwine.gameserver.command.CommandManager;
+import brainwine.gameserver.commands.CommandManager;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.entity.player.Skill;
 import brainwine.gameserver.item.Item;
diff --git a/gameserver/src/main/java/brainwine/gameserver/GameServer.java b/gameserver/src/main/java/brainwine/gameserver/GameServer.java
index d7df4f1..a115f66 100644
--- a/gameserver/src/main/java/brainwine/gameserver/GameServer.java
+++ b/gameserver/src/main/java/brainwine/gameserver/GameServer.java
@@ -9,8 +9,8 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import brainwine.gameserver.achievements.AchievementManager;
-import brainwine.gameserver.command.CommandExecutor;
-import brainwine.gameserver.command.CommandManager;
+import brainwine.gameserver.commands.CommandExecutor;
+import brainwine.gameserver.commands.CommandManager;
 import brainwine.gameserver.entity.EntityRegistry;
 import brainwine.gameserver.entity.player.NotificationType;
 import brainwine.gameserver.entity.player.PlayerManager;
diff --git a/gameserver/src/main/java/brainwine/gameserver/Naming.java b/gameserver/src/main/java/brainwine/gameserver/Naming.java
new file mode 100644
index 0000000..48cc0a2
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/Naming.java
@@ -0,0 +1,117 @@
+package brainwine.gameserver;
+
+/**
+ * TODO all I'm doing here is moving the problem somewhere else.
+ * 
+ * Entity names are sourced from: https://github.com/bytebin/deepworld-gameserver/blob/master/config/fake.yml
+ */
+public class Naming {
+    
+    private static final String[] ZONE_FIRST_NAMES = {
+        "Malvern", "Tralee", "Horncastle", "Old", "Westwood",
+        "Citta", "Tadley", "Mossley", "West", "East",
+        "North", "South", "Wadpen", "Githam", "Soatnust",
+        "Highworth", "Creakynip", "Upper", "Lower", "Cannock",
+        "Dovercourt", "Limerick", "Pickering", "Glumshed", "Crusthack",
+        "Osyltyr", "Aberstaple", "New", "Stroud", "Crumclum",
+        "Crumsidle", "Bankswund", "Fiddletrast", "Bournpan", "St.",
+        "Funderbost", "Bexwoddly", "Pilkingheld", "Wittlepen", "Rabbitbleaker",
+        "Griffingumby", "Guilthead", "Bigglelund", "Bunnymold", "Rosesidle",
+        "Crushthorn", "Tanlyward", "Ahncrace", "Pilkingking", "Dingstrath",
+        "Axebury", "Ginglingtap", "Ballybibby", "Shadehoven"
+    };
+    
+    private static final String[] ZONE_LAST_NAMES = {
+        "Falls", "Alloa", "Glen", "Way", "Dolente",
+        "Peak", "Heights", "Creek", "Banffshire", "Chagford",
+        "Gorge", "Valley", "Catacombs", "Depths", "Mines",
+        "Crickbridge", "Guildbost", "Pits", "Vaults", "Ruins",
+        "Dell", "Keep", "Chatterdin", "Scrimmance", "Gitwick",
+        "Ridge", "Alresford", "Place", "Bridge", "Glade",
+        "Mill", "Court", "Dooftory", "Hills", "Specklewint",
+        "Grove", "Aylesbury", "Wagwouth", "Russetcumby", "Point",
+        "Canyon", "Cranwarry", "Bluff", "Passage", "Crantippy",
+        "Kerbodome", "Dale", "Cemetery"
+    };
+    
+    public static final String[] ENTITY_FIRST_NAMES = {
+        "Aaron", "Abby", "Abigale", "Abraham", "Ada", "Adella", "Agnes", "Alan", 
+        "Albert", "Alexander", "Allie", "Almira", "Almyra", "Alonzo", "Alva", "Ambrose", 
+        "Amelia", "Amon", "Amos", "Andrew", "Ann", "Annie", "Aquilla", "Archibald", 
+        "Arnold", "Arrah", "Asa", "Augustus", "Barnabas", "Bartholomew", "Beatrice", "Becky", 
+        "Benedict", "Benjamin", "Bennet", "Bernard", "Bernice", "Bertram", "Bess", "Bessie", 
+        "Beth", "Betsy", "Buford", "Byron", "Calvin", "Charity", "Charles", "Charlotte", 
+        "Chastity", "Christopher", "Claire", "Clarence", "Clement", "Clinton", "Cole", "Columbus", 
+        "Commodore Perry", "Constance", "Cynthia", "Daniel", "David", "Dick", "Dorothy", "Edith", 
+        "Edmund", "Edna", "Edward", "Edwin", "Edwina", "Eldon", "Eleanor", "Eli", 
+        "Elijah", "Eliza", "Elizabeth", "Ella", "Ellie", "Elvira", "Emma", "Emmett", 
+        "Enoch", "Esther", "Ethel", "Ettie", "Eudora", "Eva", "Ezekiel", "Ezra", 
+        "Fanny", "Fidelia", "Flora", "Florence", "Frances", "Francis", "Franklin", "Frederick", 
+        "Gabriel", "Garrett", "Geneve", "Genevieve", "George", "George", "Georgia", "Gertie", 
+        "Gertrude", "Gideon", "Gilbert", "Ginny", "Gladys", "Grace", "Granville", "Hannah", 
+        "Harland", "Harold", "Harrison", "Harvey", "Hattie", "Helen", "Helene", "Henrietta", 
+        "Henry", "Hester", "Hettie", "Hiram", "Hope", "Horace", "Horatio", "Hortence", 
+        "Hugh", "Isaac", "Isaac Newton", "Isabella", "Isaiah", "Israel", "Jacob", "James", 
+        "Jane", "Jasper", "Jedediah", "Jefferson", "Jennie", "Jeptha", "Jessamine", "Jesse", 
+        "Joel", "John Paul", "John Wesley", "Jonathan", "Joseph", "Josephine", "Josephus", "Joshua", 
+        "Josiah", "Judith", "Julia", "Julian", "Juliet", "Julius", "Katherine", "Lafayette", 
+        "Laura", "Lawrence", "Leah", "Leander", "Lenora", "Les", "Letitia", "Levi", 
+        "Levi", "Lewis", "Lila", "Lilly", "Liza", "Lorena", "Lorraine", "Lottie", 
+        "Louis", "Louisa", "Louise", "Lucas", "Lucas", "Lucian", "Lucian", "Lucius", 
+        "Lucius", "Lucy", "Luke", "Luke", "Lulu", "Luther", "Luther", "Lydia", 
+        "Mahulda", "Marcellus", "Margaret", "Mark", "Martha", "Martin", "Mary", "Mary Elizabeth", 
+        "Mary Frances", "Masheck", "Matilda", "Matthew", "Maude", "Maurice", "Maxine", "Maxwell", 
+        "Mercy", "Meriwether", "Meriwether Lewis", "Merrill", "Mildred", "Minerva", "Missouri", "Molly", 
+        "Mordecai", "Morgan", "Morris", "Myrtle", "Nancy", "Natalie", "Nathaniel", "Ned", 
+        "Nellie", "Nettie", "Newton", "Nicholas", "Nimrod", "Ninian", "Nora", "Obediah", 
+        "Octavius", "Orpha", "Orville", "Oscar", "Owen", "Parthena", "Patrick", "Patrick Henry", 
+        "Patsy", "Paul", "Paul", "Peggy", "Permelia", "Perry", "Peter", "Philomena", 
+        "Phoebe", "Pleasant", "Polly", "Preshea", "Rachel", "Ralph", "Raymond", "Rebecca", 
+        "Reuben", "Rhoda", "Richard", "Robert", "Robert Lee", "Roderick", "Rowena", "Rudolph", 
+        "Rufina", "Rufus", "Ruth", "Sally", "Sam Houston", "Samantha", "Samuel", "Sarah", 
+        "Sarah Ann", "Sarah Elizabeth", "Savannah", "Selina", "Seth", "Silas", "Simeon", "Simon", 
+        "Sophronia", "Stanley", "Stella", "Stephen", "Thaddeus", "Theodore", "Theodosia", "Thomas", 
+        "Timothy", "Ulysses", "Uriah", "Vertiline", "Victor", "Victoria", "Virginia", "Vivian", 
+        "Walter", "Warren", "Washington", "Wilfred", "William", "Winnifred", "Zachariah", "Zebulon", 
+        "Zedock", "Zona", "Zylphia"
+    };
+    
+    public static final String[] ENTITY_LAST_NAMES = {
+        "Abraham", "Adams", "Alcorn", "Alderdice", "Angus", "Ashdown", "Ayre", "Backhaus", 
+        "Baldwin", "Bamford", "Beaton", "Blackwood", "Blair", "Blewett", "Bornholdt", "Bowden", 
+        "Burrows", "Cameron", "Carroll", "Clarke", "Claxton", "Collins", "Colson", "Connor", 
+        "Conroy", "Cullen", "Cunningham", "Curd", "Curnow", "Cusack", "Dagon", "Dalton", 
+        "Dawes", "Desmond", "Dewar", "Dickenson", "Donnell", "Drummond", "Dunstan", "English", 
+        "Eveans", "Faraday", "Faulkner", "Fitzgerald", "Fitzpatrick", "Fletcher", "Foster", "Franklin", 
+        "Fulton", "Gallagher", "Gibbons", "Gilmore", "Glover", "Goodfellow", "Goodwin", "Griffiths", 
+        "Gullifer", "Hadley", "Haeffner", "Hanlon", "Harding", "Harris", "Holloway", "Hughes", 
+        "Jarvis", "Jefferies", "Johnstone", "Kaylock", "Keane", "Kemp", "Kernaghan", "Kirby", 
+        "Kirkland", "Knight", "LaFontaine", "Lawford", "Lawrence", "Lennox", "Longley", "Lonsdale", 
+        "Luckett", "Lyons", "Macklin", "Madill", "Marsden", "Marshall", "Martin", "Mather", 
+        "Mathieson", "Maunder", "McColl", "McDermott", "McGillicuddy", "McKenzie", "McLachlan", "McNeil", 
+        "Meaklim", "Meighan", "Mellor", "Meyers", "Milsom", "Mitchell", "Mitchelson", "Moore", 
+        "Morgan", "Morrison", "Mortimer", "Moulsdale", "Murphy", "Nelson", "Nolan", "Noonan", 
+        "O'Keefe", "O'Sullivan", "Palmer", "Parnell", "Pattison", "Pettit", "Phillips", "Pinner", 
+        "Porter", "Prosser", "Ramseyer", "Renton", "Rickard", "Riddington", "Roche", "Rowe", 
+        "Russell", "Salisbury", "Saunders", "Sawyer", "Scanlan", "Scarborough", "Schwarer", "Sheary", 
+        "Sheedy", "Shelton", "Shields", "Shinnick", "Skinner", "Sommer", "Spencer", "Stanbury", 
+        "Stanton", "Storey", "Swaisbrick", "Thorley", "Thumpston", "Tichborne", "Tinning", "Tobin", 
+        "Todd", "Trimble", "Twomey", "Upton", "Urwin", "Vandenburg", "Vinge", "Wakefield", 
+        "Wakenshaw", "Walden", "Wallace", "Walton", "Warner", "Webb", "Whitehill", "Wickes", 
+        "Wilberforce", "Wilkinson", "Wolstenholme", "Wright"
+    };
+    
+    public static String getRandomZoneName() {
+        return getRandomName(ZONE_FIRST_NAMES, ZONE_LAST_NAMES);
+    }
+    
+    public static String getRandomEntityName() {
+        return getRandomName(ENTITY_FIRST_NAMES, ENTITY_LAST_NAMES);
+    }
+    
+    private static String getRandomName(String[] firstNames, String[] lastNames) {
+        String firstName = firstNames[(int)(Math.random() * firstNames.length)];
+        String lastName = lastNames[(int)(Math.random() * lastNames.length)];
+        return firstName + " " + lastName;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/Timer.java b/gameserver/src/main/java/brainwine/gameserver/Timer.java
new file mode 100644
index 0000000..7c817fc
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/Timer.java
@@ -0,0 +1,42 @@
+package brainwine.gameserver;
+
+/**
+ * Model for synchronous timers.
+ */
+public class Timer<T> {
+    
+    private T key;
+    private long time;
+    private Runnable action;
+    
+    public Timer(T key, long delay, Runnable action) {
+        this.key = key;
+        this.time = System.currentTimeMillis() + delay;
+        this.action = action;
+    }
+    
+    public boolean process() {
+        return process(false);
+    }
+    
+    public boolean process(boolean force) {
+        if(force || System.currentTimeMillis() >= time) {
+            action.run();
+            return true;
+        }
+        
+        return false;
+    }
+    
+    public T getKey() {
+        return key;
+    }
+    
+    public long getTime() {
+        return time;
+    }
+    
+    public Runnable getAction() {
+        return action;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/achievements/Achievement.java b/gameserver/src/main/java/brainwine/gameserver/achievements/Achievement.java
index c74fb03..00fbfb2 100644
--- a/gameserver/src/main/java/brainwine/gameserver/achievements/Achievement.java
+++ b/gameserver/src/main/java/brainwine/gameserver/achievements/Achievement.java
@@ -28,6 +28,8 @@ import brainwine.gameserver.util.MathUtils;
     @Type(name = "ScavengingAchievement", value = ScavengingAchievement.class),
     @Type(name = "DiscoveryAchievement", value = DiscoveryAchievement.class),
     @Type(name = "SpawnerStoppageAchievement", value = SpawnerStoppageAchievement.class),
+    @Type(name = "UndertakerAchievement", value = UndertakerAchievement.class),
+    @Type(name = "TrappingAchievement", value = TrappingAchievement.class),
     @Type(name = "Journeyman", value = JourneymanAchievement.class)
 })
 @JsonSerialize(using = AchievementSerializer.class)
diff --git a/gameserver/src/main/java/brainwine/gameserver/achievements/TrappingAchievement.java b/gameserver/src/main/java/brainwine/gameserver/achievements/TrappingAchievement.java
new file mode 100644
index 0000000..72cf2b2
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/achievements/TrappingAchievement.java
@@ -0,0 +1,19 @@
+package brainwine.gameserver.achievements;
+
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
+
+import brainwine.gameserver.entity.player.Player;
+
+public class TrappingAchievement extends Achievement {
+    
+    @JsonCreator
+    public TrappingAchievement(@JacksonInject("title") String title) {
+        super(title);
+    }
+
+    @Override
+    public int getProgress(Player player) {
+        return player.getStatistics().getTotalTrappings();
+    }
+}
\ No newline at end of file
diff --git a/gameserver/src/main/java/brainwine/gameserver/achievements/UndertakerAchievement.java b/gameserver/src/main/java/brainwine/gameserver/achievements/UndertakerAchievement.java
new file mode 100644
index 0000000..591965d
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/achievements/UndertakerAchievement.java
@@ -0,0 +1,19 @@
+package brainwine.gameserver.achievements;
+
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
+
+import brainwine.gameserver.entity.player.Player;
+
+public class UndertakerAchievement extends Achievement {
+    
+    @JsonCreator
+    public UndertakerAchievement(@JacksonInject("title") String title) {
+        super(title);
+    }
+
+    @Override
+    public int getProgress(Player player) {
+        return player.getStatistics().getUndertakings();
+    }
+}
\ No newline at end of file
diff --git a/gameserver/src/main/java/brainwine/gameserver/annotations/CommandInfo.java b/gameserver/src/main/java/brainwine/gameserver/annotations/CommandInfo.java
new file mode 100644
index 0000000..9dd4d13
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/annotations/CommandInfo.java
@@ -0,0 +1,15 @@
+package brainwine.gameserver.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface CommandInfo {
+    
+    public String name();
+    public String description();
+    public String[] aliases() default {};
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/Command.java b/gameserver/src/main/java/brainwine/gameserver/command/Command.java
deleted file mode 100644
index 3c42d26..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/command/Command.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package brainwine.gameserver.command;
-
-public abstract class Command {
-    
-    public abstract void execute(CommandExecutor executor, String[] args);
-    
-    public abstract String getName();
-    
-    public String[] getAliases() {
-        return null;
-    }
-    
-    public String getDescription() {
-        return "No description for this command";
-    }
-    
-    public String getUsage(CommandExecutor executor) {
-        return "/" + getName();
-    }
-    
-    public boolean canExecute(CommandExecutor executor) {
-        return true;
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java b/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java
deleted file mode 100644
index 2a731f2..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java
+++ /dev/null
@@ -1,182 +0,0 @@
-package brainwine.gameserver.command;
-
-import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
-import static brainwine.shared.LogMarkers.SERVER_MARKER;
-
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-
-import brainwine.gameserver.command.commands.AcidityCommand;
-import brainwine.gameserver.command.commands.AdminCommand;
-import brainwine.gameserver.command.commands.BanCommand;
-import brainwine.gameserver.command.commands.BroadcastCommand;
-import brainwine.gameserver.command.commands.EntityCommand;
-import brainwine.gameserver.command.commands.ExperienceCommand;
-import brainwine.gameserver.command.commands.ExportCommand;
-import brainwine.gameserver.command.commands.GenerateZoneCommand;
-import brainwine.gameserver.command.commands.GiveCommand;
-import brainwine.gameserver.command.commands.HealthCommand;
-import brainwine.gameserver.command.commands.HelpCommand;
-import brainwine.gameserver.command.commands.ImportCommand;
-import brainwine.gameserver.command.commands.KickCommand;
-import brainwine.gameserver.command.commands.LevelCommand;
-import brainwine.gameserver.command.commands.MuteCommand;
-import brainwine.gameserver.command.commands.PlayerIdCommand;
-import brainwine.gameserver.command.commands.PositionCommand;
-import brainwine.gameserver.command.commands.PrefabListCommand;
-import brainwine.gameserver.command.commands.RegisterCommand;
-import brainwine.gameserver.command.commands.RickrollCommand;
-import brainwine.gameserver.command.commands.SayCommand;
-import brainwine.gameserver.command.commands.SeedCommand;
-import brainwine.gameserver.command.commands.SettleLiquidsCommand;
-import brainwine.gameserver.command.commands.SkillPointsCommand;
-import brainwine.gameserver.command.commands.StopCommand;
-import brainwine.gameserver.command.commands.TeleportCommand;
-import brainwine.gameserver.command.commands.ThinkCommand;
-import brainwine.gameserver.command.commands.TimeCommand;
-import brainwine.gameserver.command.commands.UnbanCommand;
-import brainwine.gameserver.command.commands.UnmuteCommand;
-import brainwine.gameserver.command.commands.WeatherCommand;
-import brainwine.gameserver.command.commands.ZoneIdCommand;
-import brainwine.gameserver.entity.player.Player;
-
-public class CommandManager {
-    
-    public static final String CUSTOM_COMMAND_PREFIX = "!"; // TODO configurable
-    private static final Logger logger = LogManager.getLogger();
-    private static final Map<String, Command> commands = new HashMap<>();
-    private static final Map<String, Command> aliases = new HashMap<>();
-    private static boolean initialized = false;
-    
-    public static void init() {
-        if(initialized) {
-            logger.warn(SERVER_MARKER, "CommandManager is already initialized - skipping!");
-            return;
-        }
-        
-        registerCommands();
-        initialized = true;
-    }
-    
-    private static void registerCommands() {
-        logger.info(SERVER_MARKER, "Registering commands ...");
-        registerCommand(new StopCommand());
-        registerCommand(new RegisterCommand());
-        registerCommand(new TeleportCommand());
-        registerCommand(new KickCommand());
-        registerCommand(new MuteCommand());
-        registerCommand(new UnmuteCommand());
-        registerCommand(new BanCommand());
-        registerCommand(new UnbanCommand());
-        registerCommand(new SayCommand());
-        registerCommand(new ThinkCommand());
-        registerCommand(new BroadcastCommand());
-        registerCommand(new PlayerIdCommand());
-        registerCommand(new ZoneIdCommand());
-        registerCommand(new AdminCommand());
-        registerCommand(new HelpCommand());
-        registerCommand(new GiveCommand());
-        registerCommand(new GenerateZoneCommand());
-        registerCommand(new SeedCommand());
-        registerCommand(new PrefabListCommand());
-        registerCommand(new ExportCommand());
-        registerCommand(new ImportCommand());
-        registerCommand(new PositionCommand());
-        registerCommand(new RickrollCommand());
-        registerCommand(new EntityCommand());
-        registerCommand(new HealthCommand());
-        registerCommand(new ExperienceCommand());
-        registerCommand(new LevelCommand());
-        registerCommand(new SkillPointsCommand());
-        registerCommand(new SettleLiquidsCommand());
-        registerCommand(new WeatherCommand());
-        registerCommand(new AcidityCommand());
-        registerCommand(new TimeCommand());
-    }
-    
-    public static void executeCommand(CommandExecutor executor, String commandLine) {
-        if(commandLine.isEmpty()) {
-            return;
-        }
-        
-        commandLine.trim().replaceAll(" +", " ");
-        String[] sections = commandLine.split(" ", 2);
-        
-        if(sections.length == 0) {
-            return;
-        }
-        
-        String name = sections[0];
-        String[] args = sections.length > 1 ? sections[1].split(" ") : new String[0];
-        executeCommand(executor, name, args);
-    }
-    
-    public static void executeCommand(CommandExecutor executor, String commandName, String[] args) {
-        if(!(executor instanceof Player) && commandName.startsWith(CUSTOM_COMMAND_PREFIX) || commandName.startsWith("/")) {
-            commandName = commandName.substring(1);
-        }
-        
-        Command command = getCommand(commandName, true);
-        
-        if(command == null || !command.canExecute(executor)) {
-            executor.notify("Unknown command. Type '/help' for a list of commands.", SYSTEM);
-            return;
-        }
-        
-        if(executor instanceof Player) {
-            Player player = (Player)executor;
-            logger.info(SERVER_MARKER, "{} used command '/{}'", player.getName(), commandName + (args.length == 0 ? "" : " " + String.join(" ", args)));
-        }
-        
-        command.execute(executor, args);
-    }
-    
-    public static void registerCommand(Command command) {
-        String name = command.getName();
-        
-       if(commands.containsKey(name)) {
-           logger.warn(SERVER_MARKER, "Attempted to register duplicate command {} with name {}", command.getClass(), name);
-           return;
-       }
-       
-       commands.put(name, command);
-       String[] aliases = command.getAliases();
-       
-       if(aliases != null) {
-           for(String alias : aliases) {
-               if(commands.containsKey(alias) || CommandManager.aliases.containsKey(alias)) {
-                   logger.warn(SERVER_MARKER, "Duplicate alias {} for command {}", alias, command.getClass());
-                   continue;
-               }
-               
-               CommandManager.aliases.put(alias, command);
-           }
-       }
-    }
-    
-    public static Set<String> getCommandNames() {
-        Set<String> names = new HashSet<>();
-        names.addAll(commands.keySet());
-        names.addAll(aliases.keySet());
-        return names;
-    }
-    
-    public static Command getCommand(String name) {
-        return getCommand(name, false);
-    }
-    
-    public static Command getCommand(String name, boolean allowAlias) {
-        return commands.getOrDefault(name, allowAlias ? aliases.get(name) : null);
-    }
-    
-    public static Collection<Command> getCommands() {
-        return Collections.unmodifiableCollection(commands.values());
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/EntityCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/commands/EntityCommand.java
deleted file mode 100644
index f52ad8e..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/EntityCommand.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package brainwine.gameserver.command.commands;
-
-import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
-
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
-import brainwine.gameserver.entity.EntityConfig;
-import brainwine.gameserver.entity.EntityRegistry;
-import brainwine.gameserver.entity.npc.Npc;
-import brainwine.gameserver.entity.player.NotificationType;
-import brainwine.gameserver.entity.player.Player;
-import brainwine.gameserver.zone.Zone;
-
-public class EntityCommand extends Command {
-    
-    @Override
-    public void execute(CommandExecutor executor, String[] args) {
-        if(args.length == 0) {
-            executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM);
-            return;
-        }
-        
-        Player player = (Player)executor;
-        String name = args[0];
-        EntityConfig config = EntityRegistry.getEntityConfig(name);
-        
-        if(config == null) {
-            executor.notify(String.format("Entity with name '%s' does not exist.", name), NotificationType.SYSTEM);
-            return;
-        }
-        
-        Zone zone = player.getZone();
-        zone.spawnEntity(new Npc(zone, config), (int)player.getX(), (int)player.getY(), true);
-    }
-
-    @Override
-    public String getName() {
-        return "entity";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Spawns an entity at your current location.";
-    }
-    
-    @Override
-    public String getUsage(CommandExecutor executor) {
-        return "/entity <name>";
-    }
-    
-    @Override
-    public boolean canExecute(CommandExecutor executor) {
-        return executor.isAdmin() && executor instanceof Player;
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/RickrollCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/commands/RickrollCommand.java
deleted file mode 100644
index 1113787..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/RickrollCommand.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package brainwine.gameserver.command.commands;
-
-import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
-
-import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
-import brainwine.gameserver.entity.player.Player;
-import brainwine.gameserver.server.messages.EventMessage;
-
-public class RickrollCommand extends Command {
-    
-    @Override
-    public void execute(CommandExecutor executor, String[] args) {
-        if(args.length < 1) {
-            executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM);
-            return;
-        }
-        
-        Player player = GameServer.getInstance().getPlayerManager().getPlayer(args[0]);
-        
-        if(player == null) {
-            executor.notify("This player does not exist.", SYSTEM);
-            return;
-        } else if(!player.isOnline()) {
-            executor.notify("This player is offline.", SYSTEM);
-            return;
-        } else if(!player.isV3()) {
-            executor.notify("Cannot open URLs on iOS clients.", SYSTEM);
-            return;
-        }
-        
-        player.sendMessage(new EventMessage("openUrl", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
-        executor.notify(String.format("Successfully rickrolled %s!", player.getName()), SYSTEM);
-    }
-
-    @Override
-    public String getName() {
-        return "rickroll";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Makes a player hate you forever.";
-    }
-    
-    @Override
-    public String getUsage(CommandExecutor executor) {
-        return "/rickroll <player>";
-    }
-    
-    @Override
-    public boolean canExecute(CommandExecutor executor) {
-        return executor.isAdmin();
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/StopCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/commands/StopCommand.java
deleted file mode 100644
index b7054ad..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/StopCommand.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package brainwine.gameserver.command.commands;
-
-import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
-
-public class StopCommand extends Command {
-
-    @Override
-    public void execute(CommandExecutor executor, String[] args) {
-        GameServer.getInstance().stopGracefully(); // YEET!!
-    }
-    
-    @Override
-    public String getName() {
-        return "stop";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "exit", "close", "shutdown" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Gracefully shuts down the server after the current tick.";
-    }
-    
-    @Override
-    public boolean canExecute(CommandExecutor executor) {
-        return executor.isAdmin();
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/commands/Command.java b/gameserver/src/main/java/brainwine/gameserver/commands/Command.java
new file mode 100644
index 0000000..c655597
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/Command.java
@@ -0,0 +1,11 @@
+package brainwine.gameserver.commands;
+
+public abstract class Command {
+    
+    public abstract void execute(CommandExecutor executor, String[] args);
+    public abstract String getUsage(CommandExecutor executor);
+    
+    public boolean canExecute(CommandExecutor executor) {
+        return true;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/CommandExecutor.java b/gameserver/src/main/java/brainwine/gameserver/commands/CommandExecutor.java
similarity index 83%
rename from gameserver/src/main/java/brainwine/gameserver/command/CommandExecutor.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/CommandExecutor.java
index 9929a1b..cda63e7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/CommandExecutor.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/CommandExecutor.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command;
+package brainwine.gameserver.commands;
 
 import brainwine.gameserver.entity.player.NotificationType;
 
diff --git a/gameserver/src/main/java/brainwine/gameserver/commands/CommandManager.java b/gameserver/src/main/java/brainwine/gameserver/commands/CommandManager.java
new file mode 100644
index 0000000..e82da30
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/CommandManager.java
@@ -0,0 +1,148 @@
+package brainwine.gameserver.commands;
+
+import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
+import static brainwine.shared.LogMarkers.SERVER_MARKER;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.reflections.Reflections;
+
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.entity.player.Player;
+
+@SuppressWarnings("unchecked")
+public class CommandManager {
+    
+    public static final String CUSTOM_COMMAND_PREFIX = "!"; // TODO configurable
+    private static final Logger logger = LogManager.getLogger();
+    private static final Map<String, Command> commands = new HashMap<>();
+    private static final Map<String, Command> aliases = new HashMap<>();
+    private static boolean initialized = false;
+    
+    public static void init() {
+        if(initialized) {
+            logger.warn(SERVER_MARKER, "CommandManager is already initialized - skipping!");
+            return;
+        }
+        
+        registerCommands();
+        initialized = true;
+    }
+    
+    private static void registerCommands() {
+        logger.info(SERVER_MARKER, "Registering commands ...");
+        Reflections reflections = new Reflections("brainwine.gameserver.commands");
+        Set<Class<?>> classes = reflections.getTypesAnnotatedWith(CommandInfo.class);
+        
+        for(Class<?> clazz : classes) {
+            if(!Command.class.isAssignableFrom(clazz)) {
+                logger.warn(SERVER_MARKER, "Attempted to register non-command class {}", clazz.getSimpleName());
+                continue;
+            }
+            
+            registerCommand((Class<? extends Command>)clazz);
+        }
+    }
+    
+    public static void executeCommand(CommandExecutor executor, String commandLine) {
+        if(commandLine.isEmpty()) {
+            return;
+        }
+        
+        commandLine.trim().replaceAll(" +", " ");
+        String[] sections = commandLine.split(" ", 2);
+        
+        if(sections.length == 0) {
+            return;
+        }
+        
+        String name = sections[0];
+        String[] args = sections.length > 1 ? sections[1].split(" ") : new String[0];
+        executeCommand(executor, name, args);
+    }
+    
+    public static void executeCommand(CommandExecutor executor, String commandName, String[] args) {
+        if(!(executor instanceof Player) && commandName.startsWith(CUSTOM_COMMAND_PREFIX) || commandName.startsWith("/")) {
+            commandName = commandName.substring(1);
+        }
+        
+        Command command = getCommand(commandName, true);
+        
+        if(command == null || !command.canExecute(executor)) {
+            executor.notify("Unknown command. Type '/help' for a list of commands.", SYSTEM);
+            return;
+        }
+        
+        if(executor instanceof Player) {
+            Player player = (Player)executor;
+            logger.info(SERVER_MARKER, "{} used command '/{}'", player.getName(), commandName + (args.length == 0 ? "" : " " + String.join(" ", args)));
+        }
+        
+        command.execute(executor, args);
+    }
+    
+    public static void registerCommand(Class<? extends Command> type) {
+        CommandInfo info = type.getAnnotation(CommandInfo.class);
+        
+        if(info == null) {
+            logger.warn(SERVER_MARKER, "Cannot register command '{}' because it does not have the CommandInfo annotation", type.getSimpleName());
+            return;
+        }
+        
+        String name = info.name();
+        String[] aliases = info.aliases();
+        
+        if(commands.containsKey(name)) {
+            logger.warn(SERVER_MARKER, "Attempted to register duplicate command '{}' with name '{}'", type.getSimpleName(), name);
+            return;
+        }
+        
+        Command command = null;
+        
+        try {
+            command = type.getConstructor().newInstance();
+        } catch(ReflectiveOperationException e) {
+            logger.error("Failed to not instantiate command '{}'", type.getSimpleName(), e);
+            return;
+        }
+        
+        commands.put(name, command);
+        
+        if(aliases != null) {
+            for(String alias : aliases) {
+                if(commands.containsKey(alias) || CommandManager.aliases.containsKey(alias)) {
+                    logger.warn(SERVER_MARKER, "Duplicate alias {} for command {}", alias, command.getClass());
+                    continue;
+                }
+                
+                CommandManager.aliases.put(alias, command);
+            }
+        }
+    }
+    
+    public static Set<String> getCommandNames() {
+        Set<String> names = new HashSet<>();
+        names.addAll(commands.keySet());
+        names.addAll(aliases.keySet());
+        return names;
+    }
+    
+    public static Command getCommand(String name) {
+        return getCommand(name, false);
+    }
+    
+    public static Command getCommand(String name, boolean allowAlias) {
+        return commands.getOrDefault(name, allowAlias ? aliases.get(name) : null);
+    }
+    
+    public static Collection<Command> getCommands() {
+        return Collections.unmodifiableCollection(commands.values());
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/HelpCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/HelpCommand.java
similarity index 72%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/HelpCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/HelpCommand.java
index 2516d58..8c9fcb3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/HelpCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/HelpCommand.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
@@ -8,16 +8,20 @@ import java.util.List;
 
 import org.apache.commons.lang3.math.NumberUtils;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
-import brainwine.gameserver.command.CommandManager;
+import brainwine.gameserver.annotations.CommandInfo;
 
+@CommandInfo(name = "help", description = "Displays a list of commands.")
 public class HelpCommand extends Command {
 
     @Override
     public void execute(CommandExecutor executor, String[] args) {
         List<Command> commands = new ArrayList<>(CommandManager.getCommands());
         commands.removeIf(command -> !command.canExecute(executor));
+        commands.sort((a, b) -> {
+            CommandInfo info1 = a.getClass().getAnnotation(CommandInfo.class);
+            CommandInfo info2 = b.getClass().getAnnotation(CommandInfo.class);
+            return info1.name().compareTo(info2.name());
+        });
         int pageSize = 8;
         int pageCount = (int)Math.ceil(commands.size() / (double)pageSize);
         int page = 1;
@@ -41,11 +45,12 @@ public class HelpCommand extends Command {
                     return;
                 }
                 
-                executor.notify(String.format("========== Information about '/%s' ==========", command.getName()), SYSTEM);
-                executor.notify(String.format("Description: %s", command.getDescription()), SYSTEM);
+                CommandInfo info = command.getClass().getAnnotation(CommandInfo.class);
+                executor.notify(String.format("========== Information about '/%s' ==========", info.name()), SYSTEM);
+                executor.notify(String.format("Description: %s", info.description()), SYSTEM);
                 executor.notify(String.format("Usage: %s", command.getUsage(executor)), SYSTEM);
-                executor.notify(String.format("Aliases: %s", command.getAliases() == null ? "None :("
-                        : Arrays.toString(command.getAliases())), SYSTEM);
+                executor.notify(String.format("Aliases: %s", info.aliases() == null ? "None :("
+                        : Arrays.toString(info.aliases())), SYSTEM);
                 return;
             }
         }
@@ -56,19 +61,10 @@ public class HelpCommand extends Command {
         executor.notify(String.format("========== Command List (Page %s of %s) ==========", page, pageCount), SYSTEM);
         
         for(Command command : commandsToDisplay) {
-            executor.notify(String.format("%s - %s", command.getUsage(executor), command.getDescription()), SYSTEM);
+            CommandInfo info = command.getClass().getAnnotation(CommandInfo.class);
+            executor.notify(String.format("%s - %s", command.getUsage(executor), info.description()), SYSTEM);
         }
     }
-
-    @Override
-    public String getName() {
-        return "help";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays command information.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/RegisterCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/RegisterCommand.java
similarity index 85%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/RegisterCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/RegisterCommand.java
index f8ff79d..cab3cf5 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/RegisterCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/RegisterCommand.java
@@ -1,16 +1,16 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands;
 
 import org.apache.commons.validator.routines.EmailValidator;
 import org.mindrot.jbcrypt.BCrypt;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
 import brainwine.gameserver.dialog.DialogHelper;
 import brainwine.gameserver.entity.player.Player;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
+@CommandInfo(name = "register", description = "Shows a prompt with which you can register your account.")
 public class RegisterCommand extends Command {
     
     @Override
@@ -55,13 +55,8 @@ public class RegisterCommand extends Command {
     }
     
     @Override
-    public String getName() {
-        return "register";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Shows a prompt with which you can register your account.";
+    public String getUsage(CommandExecutor executor) {
+        return "/register";
     }
     
     @Override
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/SayCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/SayCommand.java
similarity index 75%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/SayCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/SayCommand.java
index e93375b..63f82d5 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/SayCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/SayCommand.java
@@ -1,12 +1,12 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
 import brainwine.gameserver.entity.player.ChatType;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "say", description = "Shows a speech bubble to nearby players.")
 public class SayCommand extends Command {
 
     @Override
@@ -27,16 +27,6 @@ public class SayCommand extends Command {
         player.getZone().sendChatMessage(player, text, ChatType.SPEECH);
     }
     
-    @Override
-    public String getName() {
-        return "say";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Shows a speech bubble to nearby players.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/say <message>";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/ThinkCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/ThinkCommand.java
similarity index 75%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/ThinkCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/ThinkCommand.java
index e4b5240..599aa0a 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/ThinkCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/ThinkCommand.java
@@ -1,12 +1,12 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
 import brainwine.gameserver.entity.player.ChatType;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "think", description = "Shows a thought bubble to nearby players.")
 public class ThinkCommand extends Command {
 
     @Override
@@ -27,16 +27,6 @@ public class ThinkCommand extends Command {
         player.getZone().sendChatMessage(player, text, ChatType.THOUGHT);
     }
     
-    @Override
-    public String getName() {
-        return "think";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Shows a thought bubble to nearby players.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/think <message>";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/AcidityCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/AcidityCommand.java
similarity index 79%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/AcidityCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/AcidityCommand.java
index 77aa568..2bfc092 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/AcidityCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/AcidityCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.zone.Zone;
 
+@CommandInfo(name = "acidity", description = "Displays or changes the acidity in the current zone.")
 public class AcidityCommand extends Command {
     
     @Override
@@ -35,16 +37,6 @@ public class AcidityCommand extends Command {
         zone.setAcidity(value);
         executor.notify(String.format("Acidity has been set to %s in %s.", value, zone.getName()), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "acidity";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays or changes the acidity in the current zone.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/AdminCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/AdminCommand.java
similarity index 82%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/AdminCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/AdminCommand.java
index cfce46c..7106f5f 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/AdminCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/AdminCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "admin", description = "Grants or revokes administrator rights.")
 public class AdminCommand extends Command {
     
     @Override
@@ -38,16 +40,6 @@ public class AdminCommand extends Command {
         executor.notify(String.format("Changed administrator status of player %s to %s", target.getName(), admin), SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "admin";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Allows you to grant or revoke administrator rights.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/admin <player> [true|false]";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/BanCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/BanCommand.java
similarity index 84%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/BanCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/BanCommand.java
index 2296cd8..d9982db 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/BanCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/BanCommand.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
@@ -7,11 +7,13 @@ import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.util.DateTimeUtils;
 
+@CommandInfo(name = "ban", description = "Bans a player from the server.")
 public class BanCommand extends Command {
 
     @Override
@@ -58,21 +60,6 @@ public class BanCommand extends Command {
                 target.getName(), endDate.format(DateTimeFormatter.RFC_1123_DATE_TIME), reason), SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "ban";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "banish" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Bans a player from the server.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/ban <player> <duration> [reason]";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/BroadcastCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/BroadcastCommand.java
similarity index 70%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/BroadcastCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/BroadcastCommand.java
index 08c6f8a..1bb7b25 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/BroadcastCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/BroadcastCommand.java
@@ -1,13 +1,15 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.POPUP;
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "broadcast", description = "Broadcasts a message to all online players.", aliases = "bc")
 public class BroadcastCommand extends Command {
     
     @Override
@@ -26,21 +28,6 @@ public class BroadcastCommand extends Command {
         executor.notify("Your message has been broadcasted.", POPUP);
     }
     
-    @Override
-    public String getName() {
-        return "broadcast";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "bc" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Broadcasts a message to all online players.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/broadcast <message>";
diff --git a/gameserver/src/main/java/brainwine/gameserver/commands/admin/EntityCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/EntityCommand.java
new file mode 100644
index 0000000..701e240
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/EntityCommand.java
@@ -0,0 +1,39 @@
+package brainwine.gameserver.commands.admin;
+
+import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
+
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
+import brainwine.gameserver.entity.player.NotificationType;
+import brainwine.gameserver.entity.player.Player;
+
+@CommandInfo(name = "entity", description = "Spawns an entity at your current location.")
+public class EntityCommand extends Command {
+    
+    @Override
+    public void execute(CommandExecutor executor, String[] args) {
+        if(args.length == 0) {
+            executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM);
+            return;
+        }
+        
+        Player player = (Player)executor;
+        String type = args[0];
+        
+        if(player.getZone().spawnEntity(type, player.getBlockX(), player.getBlockY(), true) == null) {
+            executor.notify(String.format("Entity type '%s' does not exist.", type), NotificationType.SYSTEM);
+            return;
+        }
+    }
+    
+    @Override
+    public String getUsage(CommandExecutor executor) {
+        return "/entity <type>";
+    }
+    
+    @Override
+    public boolean canExecute(CommandExecutor executor) {
+        return executor.isAdmin() && executor instanceof Player;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/ExperienceCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ExperienceCommand.java
similarity index 81%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/ExperienceCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/ExperienceCommand.java
index 3706c4a..fdd84d7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/ExperienceCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ExperienceCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "experience", description = "Sets the experience of the target player.", aliases = { "xp", "exp" })
 public class ExperienceCommand extends Command {
     
     @Override
@@ -53,21 +55,6 @@ public class ExperienceCommand extends Command {
         target.setExperience(experience);
         executor.notify(String.format("Successfully set %s's experience to %s.", target.getName(), experience), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "experience";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "xp", "exp" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Sets the experience of the target player.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/ExportCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ExportCommand.java
similarity index 90%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/ExportCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/ExportCommand.java
index d4fdb66..399f1f4 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/ExportCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ExportCommand.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
@@ -6,13 +6,15 @@ import java.util.Arrays;
 import java.util.regex.Pattern;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.prefab.Prefab;
 import brainwine.gameserver.prefab.PrefabManager;
 import brainwine.gameserver.zone.Zone;
 
+@CommandInfo(name = "export", description = "Exports a section of a zone to a prefab file.")
 public class ExportCommand extends Command {
     
     public static final Pattern PREFAB_NAME_PATTERN = Pattern.compile("\\w+(/\\w+)*");
@@ -80,16 +82,6 @@ public class ExportCommand extends Command {
         }
     }
     
-    @Override
-    public String getName() {
-        return "export";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Exports a section of a zone to a prefab file.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/export <x> <y> <width> <height> <name>";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/GenerateZoneCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/GenerateZoneCommand.java
similarity index 79%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/GenerateZoneCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/GenerateZoneCommand.java
index f2f66ee..0036466 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/GenerateZoneCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/GenerateZoneCommand.java
@@ -1,14 +1,16 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.zone.Biome;
 import brainwine.gameserver.zone.Zone;
 import brainwine.gameserver.zone.gen.ZoneGenerator;
 
+@CommandInfo(name = "genzone", description = "Asynchronously generates a new zone.", aliases = "generate")
 public class GenerateZoneCommand extends Command {
 
     public static final int MIN_WIDTH = 200;
@@ -18,20 +20,20 @@ public class GenerateZoneCommand extends Command {
     
     @Override
     public void execute(CommandExecutor executor, String[] args) {
-        Biome biome = Biome.getRandomBiome();
-        int width = 2000;
-        int height = 600;
+        Biome biome = args.length > 0 ? Biome.fromName(args[0]) : Biome.getRandomBiome();
+        int width = biome == Biome.DEEP ? 1200 : 2000;
+        int height = biome == Biome.DEEP ? 1000 : 600;
         int seed = (int)(Math.random() * Integer.MAX_VALUE);
         
-        if(args.length > 0 && args.length < 2) {
+        if(args.length > 1 && args.length < 3) {
             executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM);
             return;
         }
         
-        if(args.length >= 2) {
+        if(args.length >= 3) {
             try {
-                width = Integer.parseInt(args[0]);
-                height = Integer.parseInt(args[1]);
+                width = Integer.parseInt(args[1]);
+                height = Integer.parseInt(args[2]);
             } catch(NumberFormatException e) {
                 executor.notify("Zone width and height must be valid numbers.", SYSTEM);
                 return;
@@ -47,10 +49,6 @@ public class GenerateZoneCommand extends Command {
             }
         }
         
-        if(args.length >= 3) {
-            biome = Biome.fromName(args[2]);
-        }
-        
         ZoneGenerator generator = null;
         
         if(args.length >= 4) {
@@ -88,25 +86,10 @@ public class GenerateZoneCommand extends Command {
             }
         });
     }
-
-    @Override
-    public String getName() {
-        return "genzone";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "generate" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Asynchronously generates a new zone.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
-        return "/genzone [<width> <height>] [biome] [generator] [seed]";
+        return "/genzone [biome] [<width> <height>] [generator] [seed]";
     }
     
     @Override
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/GiveCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/GiveCommand.java
similarity index 88%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/GiveCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/GiveCommand.java
index 6102bc8..9a0bd92 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/GiveCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/GiveCommand.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
@@ -6,12 +6,14 @@ import java.util.ArrayList;
 import java.util.List;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.ItemRegistry;
 
+@CommandInfo(name = "give", description = "Give or take items from players.")
 public class GiveCommand extends Command {
     
     @Override
@@ -35,7 +37,7 @@ public class GiveCommand extends Command {
             title = "of every item";
             
             for(Item item : ItemRegistry.getItems()) {
-                if(!item.isClothing() && !item.isAir()) {
+                if(!item.isAir()) {
                     items.add(item);
                 }
             }
@@ -84,16 +86,6 @@ public class GiveCommand extends Command {
             executor.notify(String.format("Took %s %s from %s", -quantity, title, target.getName()), SYSTEM);
         }
     }
-
-    @Override
-    public String getName() {
-        return "give";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Adds items to a player's inventory.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/HealthCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/HealthCommand.java
similarity index 81%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/HealthCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/HealthCommand.java
index 5e84fa3..2e942a8 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/HealthCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/HealthCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "health", description = "Sets the target player's health.", aliases = "hp")
 public class HealthCommand extends Command {
 
     @Override
@@ -47,21 +49,6 @@ public class HealthCommand extends Command {
             executor.notify(String.format("Set %s's health to %s", target.getName(), target.getHealth()), SYSTEM);
         }
     }
-
-    @Override
-    public String getName() {
-        return "health";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "hp" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Sets a player's health.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/ImportCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ImportCommand.java
similarity index 83%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/ImportCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/ImportCommand.java
index 7178f86..204ae6c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/ImportCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ImportCommand.java
@@ -1,13 +1,15 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.prefab.Prefab;
 
+@CommandInfo(name = "import", description = "Places a prefab at the specified location.")
 public class ImportCommand extends Command {
 
     @Override
@@ -46,16 +48,6 @@ public class ImportCommand extends Command {
         player.notify(String.format("Successfully imported '%s' @ [x: %s, y: %s, width: %s, height: %s]", 
                 name, x, y, prefab.getWidth(), prefab.getHeight()), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "import";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Places a prefab at your or a specified location.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/KickCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/KickCommand.java
similarity index 80%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/KickCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/KickCommand.java
index 1ba646b..63b94ab 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/KickCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/KickCommand.java
@@ -1,14 +1,16 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import java.util.Arrays;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "kick", description = "Kicks a player from the server.")
 public class KickCommand extends Command {
 
     @Override
@@ -38,16 +40,6 @@ public class KickCommand extends Command {
         executor.notify("Kicked player " + player.getName() + " for '" + reason + "'", SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "kick";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Kicks a player from the server.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/kick <player> [reason]";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/LevelCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/LevelCommand.java
similarity index 81%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/LevelCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/LevelCommand.java
index 0e1437a..1616b81 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/LevelCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/LevelCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "level", description = "Sets the level of the target player.", aliases = { "lvl", "lv" })
 public class LevelCommand extends Command {
     
     @Override
@@ -55,21 +57,6 @@ public class LevelCommand extends Command {
         target.setLevel(level);
         executor.notify(String.format("Successfully set %s's level to %s.", target.getName(), level), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "level";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "lvl", "lv" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Sets the level of the target player.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/MuteCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/MuteCommand.java
similarity index 83%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/MuteCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/MuteCommand.java
index 9b0a973..f32b0a1 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/MuteCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/MuteCommand.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
@@ -7,11 +7,13 @@ import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.util.DateTimeUtils;
 
+@CommandInfo(name = "mute", description = "Mutes a player, preventing them from chatting.", aliases = "silence")
 public class MuteCommand extends Command {
 
     @Override
@@ -58,21 +60,6 @@ public class MuteCommand extends Command {
                 target.getName(), endDate.format(DateTimeFormatter.RFC_1123_DATE_TIME), reason), SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "mute";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "silence", };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Mutes a player, preventing them from chatting.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/mute <player> <duration> [reason]";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/PlayerIdCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/PlayerIdCommand.java
similarity index 78%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/PlayerIdCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/PlayerIdCommand.java
index 0b0f8de..158b1f3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/PlayerIdCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/PlayerIdCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "pid", description = "Displays the document id of a player.")
 public class PlayerIdCommand extends Command {
 
     @Override
@@ -34,16 +36,6 @@ public class PlayerIdCommand extends Command {
         executor.notify(target.getDocumentId(), SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "pid";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays the document id of a player.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return String.format("/pid %s", executor instanceof Player ? "[player]" : "<player>");
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/PositionCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/PositionCommand.java
similarity index 57%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/PositionCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/PositionCommand.java
index 1ddede5..e05b716 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/PositionCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/PositionCommand.java
@@ -1,11 +1,13 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "position", description = "Displays the coordinates of the block you are standing on.", aliases = { "pos", "feet", "coords", "location" })
 public class PositionCommand extends Command {
 
     @Override
@@ -13,21 +15,6 @@ public class PositionCommand extends Command {
         Player player = (Player)executor;   
         player.notify(String.format("X: %s Y: %s", (int)player.getX(), (int)player.getY() + 1), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "pos";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "position", "feet", "coords", "location" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays the coordinates of the block you are standing on.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/PrefabListCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/PrefabListCommand.java
similarity index 76%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/PrefabListCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/PrefabListCommand.java
index 50ca8e3..dff4e21 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/PrefabListCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/PrefabListCommand.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
@@ -8,10 +8,12 @@ import java.util.List;
 import org.apache.commons.lang3.math.NumberUtils;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.prefab.Prefab;
 
+@CommandInfo(name = "prefabs", description = "Displays a list of all prefabs.")
 public class PrefabListCommand extends Command {
 
     @Override
@@ -34,21 +36,6 @@ public class PrefabListCommand extends Command {
             executor.notify(prefab.getName(), SYSTEM);
         }
     }
-
-    @Override
-    public String getName() {
-        return "prefabs";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "prefablist" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays a list of all prefabs.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/SeedCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/SeedCommand.java
similarity index 79%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/SeedCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/SeedCommand.java
index 5135e79..e5e40a0 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/SeedCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/SeedCommand.java
@@ -1,13 +1,15 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.zone.Zone;
 
+@CommandInfo(name = "seed", description = "Displays the seed of a zone.")
 public class SeedCommand extends Command {
     
     @Override
@@ -35,16 +37,6 @@ public class SeedCommand extends Command {
         executor.notify("Seed: " + target.getSeed(), SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "seed";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays the seed of a zone.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return String.format("/seed %s", executor instanceof Player ? "[zone]" : "<zone>");
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/SettleLiquidsCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/SettleLiquidsCommand.java
similarity index 63%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/SettleLiquidsCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/SettleLiquidsCommand.java
index b81d20a..fa6cf38 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/SettleLiquidsCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/SettleLiquidsCommand.java
@@ -1,11 +1,13 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "settle", description = "Settles all liquids in all active chunks in the current zone. Warning - can cause lag!")
 public class SettleLiquidsCommand extends Command {
 
     @Override
@@ -19,18 +21,8 @@ public class SettleLiquidsCommand extends Command {
     }
 
     @Override
-    public String getName() {
-        return "settleliquids";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] {"settle"};
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Settles all liquids in all active chunks in the current zone. Warning - can cause lag!";
+    public String getUsage(CommandExecutor executor) {
+        return "/settle";
     }
     
     @Override
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/SkillPointsCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/SkillPointsCommand.java
similarity index 81%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/SkillPointsCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/SkillPointsCommand.java
index f6abefc..22ccfa7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/SkillPointsCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/SkillPointsCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "skillpoints", description = "Sets the skill points of the target player.", aliases = "points")
 public class SkillPointsCommand extends Command {
     
     @Override
@@ -54,21 +56,6 @@ public class SkillPointsCommand extends Command {
         target.notify(String.format("Your skill point count has been set to %s.", amount), SYSTEM);
         executor.notify(String.format("Successfully set %s's skill point count to %s.", target.getName(), amount), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "skillpoints";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "points" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Sets the skill points of the target player.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/commands/admin/StopCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/StopCommand.java
new file mode 100644
index 0000000..b168555
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/StopCommand.java
@@ -0,0 +1,25 @@
+package brainwine.gameserver.commands.admin;
+
+import brainwine.gameserver.GameServer;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
+
+@CommandInfo(name = "stop", description = "Gracefully shuts down the server.", aliases = { "exit", "close", "shutdown" })
+public class StopCommand extends Command {
+
+    @Override
+    public void execute(CommandExecutor executor, String[] args) {
+        GameServer.getInstance().stopGracefully(); // YEET!!
+    }
+    
+    @Override
+    public String getUsage(CommandExecutor executor) {
+        return "/stop";
+    }
+    
+    @Override
+    public boolean canExecute(CommandExecutor executor) {
+        return executor.isAdmin();
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/TeleportCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/TeleportCommand.java
similarity index 73%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/TeleportCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/TeleportCommand.java
index 387d0d6..d09952c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/TeleportCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/TeleportCommand.java
@@ -1,11 +1,13 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "teleport", description = "Teleports you to the specified position.", aliases = "tp")
 public class TeleportCommand extends Command {
 
     @Override
@@ -35,21 +37,6 @@ public class TeleportCommand extends Command {
         player.teleport(x, y);
     }
     
-    @Override
-    public String getName() {
-        return "teleport";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "tp" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Teleports you to the specified position.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return "/teleport <x> <y>";
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/TimeCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/TimeCommand.java
similarity index 84%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/TimeCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/TimeCommand.java
index e61c24e..5790328 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/TimeCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/TimeCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.zone.Zone;
 
+@CommandInfo(name = "time", description = "Displays or changes the time in the current zone.")
 public class TimeCommand extends Command {
     
     @Override
@@ -45,16 +47,6 @@ public class TimeCommand extends Command {
         zone.setTime(value);
         executor.notify(String.format("Time has been set to %s in %s.", value, zone.getName()), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "time";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays or changes the time in the current zone.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/UnbanCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/UnbanCommand.java
similarity index 75%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/UnbanCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/UnbanCommand.java
index eecb4ed..934f4a2 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/UnbanCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/UnbanCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "unban", description = "Unbans a player.", aliases = "pardon")
 public class UnbanCommand extends Command {
 
     @Override
@@ -31,21 +33,6 @@ public class UnbanCommand extends Command {
         target.unban(executor instanceof Player ? (Player)executor : null);
         executor.notify(String.format("Player %s has been unbanned.", target.getName()), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "unban";
-    }
-    
-    @Override
-    public String[] getAliases() {
-        return new String[] { "pardon" };
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Unbans a player.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/UnmuteCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/UnmuteCommand.java
similarity index 79%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/UnmuteCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/UnmuteCommand.java
index 185a145..23803c8 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/UnmuteCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/UnmuteCommand.java
@@ -1,12 +1,14 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 
+@CommandInfo(name = "unmute", description = "Unmutes a player.")
 public class UnmuteCommand extends Command {
 
     @Override
@@ -31,16 +33,6 @@ public class UnmuteCommand extends Command {
         target.unmute(executor instanceof Player ? (Player)executor : null);
         executor.notify(String.format("Player %s has been unmuted.", target.getName()), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "unmute";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Unmutes a player.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/WeatherCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/WeatherCommand.java
similarity index 82%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/WeatherCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/WeatherCommand.java
index e7027c7..4b8ea6c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/WeatherCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/WeatherCommand.java
@@ -1,14 +1,16 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.zone.Biome;
 import brainwine.gameserver.zone.WeatherManager;
 import brainwine.gameserver.zone.Zone;
 
+@CommandInfo(name = "weather", description = "Displays or changes the weather in the current zone.")
 public class WeatherCommand extends Command {
     
     @Override
@@ -32,16 +34,6 @@ public class WeatherCommand extends Command {
         zone.getWeatherManager().createRandomRain(dry);
         executor.notify(String.format("Weather has been %s in %s.", dry ? "cleared" : "made rainy", zone.getName()), SYSTEM);
     }
-
-    @Override
-    public String getName() {
-        return "weather";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays or changes the weather in the current zone.";
-    }
     
     @Override
     public String getUsage(CommandExecutor executor) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/command/commands/ZoneIdCommand.java b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ZoneIdCommand.java
similarity index 79%
rename from gameserver/src/main/java/brainwine/gameserver/command/commands/ZoneIdCommand.java
rename to gameserver/src/main/java/brainwine/gameserver/commands/admin/ZoneIdCommand.java
index 11bf20a..c674a8a 100644
--- a/gameserver/src/main/java/brainwine/gameserver/command/commands/ZoneIdCommand.java
+++ b/gameserver/src/main/java/brainwine/gameserver/commands/admin/ZoneIdCommand.java
@@ -1,13 +1,15 @@
-package brainwine.gameserver.command.commands;
+package brainwine.gameserver.commands.admin;
 
 import static brainwine.gameserver.entity.player.NotificationType.SYSTEM;
 
 import brainwine.gameserver.GameServer;
-import brainwine.gameserver.command.Command;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.annotations.CommandInfo;
+import brainwine.gameserver.commands.Command;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.zone.Zone;
 
+@CommandInfo(name = "zid", description = "Displays the document id of a zone.")
 public class ZoneIdCommand extends Command {
     
     @Override
@@ -35,16 +37,6 @@ public class ZoneIdCommand extends Command {
         executor.notify(target.getDocumentId(), SYSTEM);
     }
     
-    @Override
-    public String getName() {
-        return "zid";
-    }
-    
-    @Override
-    public String getDescription() {
-        return "Displays the document id of a zone.";
-    }
-    
     @Override
     public String getUsage(CommandExecutor executor) {
         return String.format("/zid %s", executor instanceof Player ? "[zone]" : "<zone>");
diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/Dialog.java b/gameserver/src/main/java/brainwine/gameserver/dialog/Dialog.java
index d931eed..cd2690c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/dialog/Dialog.java
+++ b/gameserver/src/main/java/brainwine/gameserver/dialog/Dialog.java
@@ -1,12 +1,15 @@
 package brainwine.gameserver.dialog;
 
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.annotation.JsonSetter;
 
 @JsonInclude(Include.NON_DEFAULT)
 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -15,6 +18,7 @@ public class Dialog {
     private DialogType type = DialogType.STANDARD;
     private DialogAlignment alignment = DialogAlignment.LEFT;
     private List<DialogSection> sections = new ArrayList<>();
+    private Object actions;
     private String title;
     private String target;
     
@@ -61,6 +65,29 @@ public class Dialog {
         return sections;
     }
     
+    @JsonSetter
+    private void setActions(Object actions) {
+        this.actions = actions;
+    }
+    
+    public Dialog setActions(String actions) {
+        this.actions = actions;
+        return this;
+    }
+    
+    public Dialog setActions(String... actions) {
+        return setActions(Arrays.asList(actions));
+    }
+    
+    public Dialog setActions(Collection<String> actions) {
+        this.actions = actions;
+        return this;
+    }
+    
+    public Object getActions() {
+        return actions;
+    }
+    
     public Dialog setTitle(String title) {
         this.title = title;
         return this;
diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java b/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java
index 9374d8a..5f6f48f 100644
--- a/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java
+++ b/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java
@@ -3,13 +3,15 @@ package brainwine.gameserver.dialog;
 import java.util.ArrayList;
 import java.util.List;
 
+import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonFormat.Shape;
 
 import brainwine.gameserver.dialog.input.DialogInput;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
+import brainwine.gameserver.util.Vector2i;
 
 @JsonInclude(Include.NON_DEFAULT)
 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -20,6 +22,7 @@ public class DialogSection {
     private String text;
     private String textColor;
     private double textScale;
+    private Vector2i location;
     private DialogInput input;
     
     public DialogSection addItem(DialogListItem item) {
@@ -75,6 +78,20 @@ public class DialogSection {
         return textScale;
     }
     
+    /**
+     * v2 clients only!
+     */
+    public DialogSection setLocation(int x, int y) {
+        this.location = new Vector2i(x, y);
+        return this;
+    }
+    
+    @JsonProperty("map")
+    @JsonFormat(shape = Shape.ARRAY)
+    public Vector2i getLocation() {
+        return location;
+    }
+    
     public DialogSection setInput(DialogInput input) {
         this.input = input;
         return this;
diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java
index cffab2c..39ce767 100644
--- a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java
+++ b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java
@@ -3,9 +3,12 @@ package brainwine.gameserver.dialog.input;
 import java.util.Arrays;
 import java.util.Collection;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+
 public class DialogSelectInput extends DialogInput {
     
     private Collection<String> options;
+    private int maxColumns;
     
     public DialogSelectInput setOptions(String... options) {
         return setOptions(Arrays.asList(options));
@@ -19,4 +22,14 @@ public class DialogSelectInput extends DialogInput {
     public Collection<String> getOptions() {
         return options;
     }
+    
+    public DialogSelectInput setMaxColumns(int maxColumns) {
+        this.maxColumns = maxColumns;
+        return this;
+    }
+    
+    @JsonProperty("max columns")
+    public int getMaxColumns() {
+        return maxColumns;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java b/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java
index 202e395..5efa62a 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java
@@ -1,16 +1,24 @@
 package brainwine.gameserver.entity;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.DamageType;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemUseType;
+import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.server.Message;
+import brainwine.gameserver.server.messages.EffectMessage;
 import brainwine.gameserver.server.messages.EntityChangeMessage;
 import brainwine.gameserver.server.messages.EntityStatusMessage;
 import brainwine.gameserver.util.MapHelper;
 import brainwine.gameserver.util.MathUtils;
+import brainwine.gameserver.zone.Block;
+import brainwine.gameserver.zone.MetaBlock;
 import brainwine.gameserver.zone.Zone;
 
 public abstract class Entity {
@@ -18,8 +26,11 @@ public abstract class Entity {
     public static final float DEFAULT_HEALTH = 5;
     public static final float POSITION_MODIFIER = 100F;
     public static final int VELOCITY_MODIFIER = (int)POSITION_MODIFIER;
+    public static final int ATTACK_RETENTION_TIME = 2000;
+    public static final int ATTACK_INVINCIBLE_TIME = 333;
     protected final Map<String, Object> properties = new HashMap<>();
     protected final List<Player> trackers = new ArrayList<>();
+    protected final List<EntityAttack> recentAttacks = new ArrayList<>();
     protected int type;
     protected String name;
     protected float health = DEFAULT_HEALTH;
@@ -29,10 +40,18 @@ public abstract class Entity {
     protected float y;
     protected float velocityX;
     protected float velocityY;
+    protected int blockX;
+    protected int blockY;
+    protected int lastBlockX;
+    protected int lastBlockY;
     protected int targetX;
     protected int targetY;
+    protected int sizeX = 1;
+    protected int sizeY = 1;
     protected FacingDirection direction = FacingDirection.WEST;
     protected int animation;
+    protected boolean invulnerable;
+    protected EntityAttack lastAttack; // Used for tracking in entity deaths -- do not use this for anything else!
     protected long lastDamagedAt;
     
     public Entity(Zone zone) {
@@ -40,10 +59,16 @@ public abstract class Entity {
     }
     
     public void tick(float deltaTime) {
-        // Override
+        long now = System.currentTimeMillis();
+        
+        // Update block position
+        updateBlockPosition();
+        
+        // Clear expired recent attacks
+        recentAttacks.removeIf(attack -> now >= attack.getTime() + ATTACK_RETENTION_TIME);
     }
     
-    public void die(Player killer) {
+    public void die(EntityAttack cause) {
         // Override
     }
     
@@ -53,18 +78,93 @@ public abstract class Entity {
         }
     }
     
-    public void damage(float amount) {
-        damage(amount, null);
+    public void attack(Entity attacker, Item weapon, float baseDamage, DamageType damageType) {
+        attack(attacker, weapon, baseDamage, damageType, false);
     }
     
-    public void damage(float amount, Player attacker) {
-        setHealth(health - amount);
-        
-        if(health <= 0) {
-            die(attacker);
+    public void attack(Entity attacker, Item weapon, float baseDamage, DamageType damageType, boolean trueDamage) {
+        // Ignore attack if entity is dead or invulnerable
+        if(isDead() || isInvulnerable()) {
+            return;
         }
         
+        // Ignore attack if there is no damage to deal
+        if(baseDamage <= 0 || damageType == null || damageType == DamageType.NONE) {
+            return;
+        }
+        
+        EntityAttack attack = new EntityAttack(attacker, weapon, baseDamage, damageType);
+        recentAttacks.add(attack);
+        lastAttack = attack;
         lastDamagedAt = System.currentTimeMillis();
+        
+        // Kill entity if attacker is a player in god mode
+        if(attacker != null && attacker.isPlayer() && ((Player)attacker).isGodMode()) {
+            setHealth(0.0F);
+            return;
+        }
+        
+        // Ignore multipliers if true damage should be dealt
+        if(trueDamage) {
+            setHealth(health - baseDamage);
+            return;
+        }
+        
+        float attackMultiplier = attacker != null ? Math.max(0.0F, attacker.getAttackMultiplier(attack)) : 1.0F;
+        float defense = Math.max(0.0F, 1.0F - getDefense(attack));
+        float damage = baseDamage * attackMultiplier * defense;
+        setHealth(health - damage);
+    }
+    
+    public float getAttackMultiplier(EntityAttack attack) {
+        return 1.0F; // Override
+    }
+    
+    public float getDefense(EntityAttack attack) {
+        return 1.0F; // Override
+    }
+    
+    public void spawnEffect(String type) {
+        spawnEffect(type, 1);
+    }
+    
+    public void spawnEffect(String type, Object data) {
+        float effectX = x + sizeX / 2.0F;
+        float effectY = y + sizeY / 2.0F;
+        sendMessageToTrackers(new EffectMessage(effectX, effectY, type, data));
+    }
+    
+    public void emote(String message) {
+        float effectX = x + sizeX / 2.0F;
+        float effectY = y - sizeY + 1;
+        sendMessageToTrackers(new EffectMessage(effectX, effectY, "emote", message));
+    }
+    
+    public void updateBlockPosition() {
+        lastBlockX = blockX;
+        lastBlockY = blockY;
+        blockX = (int)x;
+        blockY = (int)y;
+        
+        // Check if block position has changed
+        if(lastBlockX != blockX || lastBlockY != blockY) {
+            blockPositionChanged();
+        }
+    }
+    
+    public void blockPositionChanged() {
+        // Check for touchplates
+        if(zone != null && zone.isChunkLoaded(blockX, blockY)) {
+            MetaBlock metaBlock = zone.getMetaBlock(blockX, blockY);
+            Block block = zone.getBlock(blockX, blockY);
+            Item item = block.getFrontItem();
+            int mod = block.getFrontMod();
+            
+            // Trigger a switch interaction if the entity stepped on a touchplate
+            if(item.hasUse(ItemUseType.TRIGGER)) {
+                ItemUseType.SWITCH.getInteraction().interact(zone, this, blockX, blockY, Layer.FRONT, item, mod, metaBlock, null, null);
+            }
+        }
     }
     
     public boolean canSee(Entity other) {
@@ -80,7 +180,7 @@ public abstract class Entity {
         return inRange(other.getX(), other.getY(), range);
     }
     
-    public boolean inRange(float x, float y, float range) {
+    public boolean inRange(float x, float y, double range) {
         return MathUtils.inRange(this.x, this.y, x, y, range);
     }
     
@@ -128,6 +228,18 @@ public abstract class Entity {
         return trackers;
     }
     
+    public boolean wasAttackedRecently(Entity entity, int delay) {
+        return recentAttacks.stream().filter(attack -> attack.getAttacker() == entity && System.currentTimeMillis() < attack.getTime() + delay).findFirst().isPresent();
+    }
+    
+    public EntityAttack getMostRecentAttack() {
+        return recentAttacks.isEmpty() ? null : recentAttacks.get(recentAttacks.size() - 1);
+    }
+    
+    public List<EntityAttack> getRecentAttacks() {
+        return Collections.unmodifiableList(recentAttacks);
+    }
+    
     public void setId(int id) {
         this.id = id;
     }
@@ -159,6 +271,12 @@ public abstract class Entity {
     public void setHealth(float health) {
         float maxHealth = getMaxHealth();
         this.health = health < 0 ? 0 : health > maxHealth ? maxHealth : health;
+        
+        if(this.health <= 0.0F) {
+            die(lastAttack);
+        }
+        
+        lastAttack = null;
     }
     
     public float getHealth() {
@@ -168,6 +286,7 @@ public abstract class Entity {
     public void setPosition(float x, float y) {
         this.x = x;
         this.y = y;
+        updateBlockPosition();
     }
     
     public float getX() {
@@ -204,6 +323,22 @@ public abstract class Entity {
         return targetY;
     }
     
+    public int getBlockX() {
+        return blockX;
+    }
+    
+    public int getBlockY() {
+        return blockY;
+    }
+    
+    public int getSizeX() {
+        return sizeX;
+    }
+    
+    public int getSizeY() {
+        return sizeY;
+    }
+        
     public void setDirection(FacingDirection direction) {
         this.direction = direction;
     }
@@ -220,6 +355,14 @@ public abstract class Entity {
         return animation;
     }
     
+    public void setInvulnerable(boolean invulnerable) {
+        this.invulnerable = invulnerable;
+    }
+    
+    public boolean isInvulnerable() {
+        return invulnerable;
+    }
+    
     public void setZone(Zone zone) {
         this.zone = zone;
     }
@@ -228,6 +371,10 @@ public abstract class Entity {
         return zone;
     }
     
+    public final boolean isPlayer() {
+        return this instanceof Player; // Not very OOP
+    }
+    
     /**
      * @return A {@link Map} containing all the data necessary for use in {@link EntityStatusMessage}.
      */
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/EntityAttack.java b/gameserver/src/main/java/brainwine/gameserver/entity/EntityAttack.java
new file mode 100644
index 0000000..70ccebf
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/EntityAttack.java
@@ -0,0 +1,41 @@
+package brainwine.gameserver.entity;
+
+import brainwine.gameserver.item.DamageType;
+import brainwine.gameserver.item.Item;
+
+public class EntityAttack {
+    
+    private final Entity attacker;
+    private final Item weapon;
+    private final float baseDamage;
+    private final DamageType damageType;
+    private final long time;
+    
+    public EntityAttack(Entity attacker, Item weapon, float baseDamage, DamageType damageType) {
+        this.attacker = attacker;
+        this.weapon = weapon;
+        this.baseDamage = baseDamage;
+        this.damageType = damageType;
+        this.time = System.currentTimeMillis();
+    }
+    
+    public Entity getAttacker() {
+        return attacker;
+    }
+    
+    public Item getWeapon() {
+        return weapon;
+    }
+    
+    public float getBaseDamage() {
+        return baseDamage;
+    }
+    
+    public DamageType getDamageType() {
+        return damageType;
+    }
+    
+    public long getTime() {
+        return time;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java b/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java
index 9fec361..fdb43f8 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java
@@ -29,6 +29,11 @@ public class EntityConfig {
     private int experienceYield;
     private float maxHealth = Entity.DEFAULT_HEALTH;
     private float baseSpeed = 3;
+    private boolean character;
+    private boolean human;
+    private boolean named;
+    private boolean trappable;
+    private Item trappablePetItem;
     private Vector2i size = new Vector2i(1, 1);
     private EntityGroup group = EntityGroup.NONE;
     private WeightedMap<EntityLoot> loot = new WeightedMap<>();
@@ -79,6 +84,30 @@ public class EntityConfig {
         return baseSpeed;
     }
     
+    public boolean isCharacter() {
+        return character;
+    }
+    
+    public boolean isHuman() {
+        return human;
+    }
+    
+    public boolean isNamed() {
+        return named;
+    }
+    
+    public boolean isTrappable() {
+        return trappable;
+    }
+    
+    public boolean hasTrappablePetItem() {
+        return trappablePetItem != null && !trappablePetItem.isAir();
+    }
+    
+    public Item getTrappablePetItem() {
+        return trappablePetItem;
+    }
+    
     @JsonSetter(nulls = Nulls.SKIP)
     private void setSize(Vector2i size) {
         this.size = size;
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java
index 9953c74..db53dba 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java
@@ -9,18 +9,20 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.concurrent.ThreadLocalRandom;
 
-import brainwine.gameserver.behavior.SequenceBehavior;
+import brainwine.gameserver.Naming;
 import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.EntityAttack;
 import brainwine.gameserver.entity.EntityConfig;
 import brainwine.gameserver.entity.EntityLoot;
 import brainwine.gameserver.entity.EntityRegistry;
 import brainwine.gameserver.entity.FacingDirection;
+import brainwine.gameserver.entity.npc.behavior.SequenceBehavior;
+import brainwine.gameserver.entity.player.Appearance;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.DamageType;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.util.MapHelper;
-import brainwine.gameserver.util.Pair;
 import brainwine.gameserver.util.Vector2i;
 import brainwine.gameserver.util.WeightedMap;
 import brainwine.gameserver.zone.MetaBlock;
@@ -28,13 +30,11 @@ import brainwine.gameserver.zone.Zone;
 
 public class Npc extends Entity {
     
-    public static final int ATTACK_RETENTION_TIME = 2000;
-    public static final int ATTACK_INVINCIBLE_TIME = 333;
     private final EntityConfig config;
     private final String typeName;
     private final float maxHealth;
     private final float baseSpeed;
-    private final Vector2i size;
+    private final boolean persist;
     private final WeightedMap<EntityLoot> loot;
     private final WeightedMap<EntityLoot> placedLoot;
     private final Map<Item, WeightedMap<EntityLoot>> lootByWeapon;
@@ -43,7 +43,6 @@ public class Npc extends Entity {
     private final List<String> animations;
     private final SequenceBehavior behaviorTree;
     private final Map<DamageType, Float> activeDefenses = new HashMap<>();
-    private final Map<Player, Pair<Item, Long>> recentAttacks = new HashMap<>();
     private final List<Npc> children = new ArrayList<>();
     private float speed;
     private int moveX;
@@ -52,6 +51,7 @@ public class Npc extends Entity {
     private Vector2i mountBlock;
     private Entity owner;
     private Entity target;
+    private boolean artificial;
     private long lastBehavedAt = System.currentTimeMillis();
     private long lastTrackedAt = System.currentTimeMillis();
     
@@ -100,12 +100,24 @@ public class Npc extends Entity {
             properties.put("sl", slots);
         }
         
+        // Generate random name
+        if(config.isNamed()) {
+            this.name = Naming.getRandomEntityName();
+        }
+        
+        // Generate random appearance
+        if(config.isHuman()) {
+           properties.putAll(Appearance.getRandomAppearance());
+        }
+        
         this.config = config;
         this.typeName = config.getName();
         this.type = config.getType();
         this.maxHealth = config.getMaxHealth();
         this.baseSpeed = config.getBaseSpeed();
-        this.size = config.getSize();
+        this.persist = config.isCharacter();
+        this.sizeX = config.getSize().getX();
+        this.sizeY = config.getSize().getY();
         this.loot = config.getLoot();
         this.placedLoot = config.getPlacedLoot();
         this.lootByWeapon = config.getLootByWeapon();
@@ -120,11 +132,9 @@ public class Npc extends Entity {
     
     @Override
     public void tick(float deltaTime) {
+        super.tick(deltaTime);
         long now = System.currentTimeMillis();
         
-        // Clear expired recent attacks
-        recentAttacks.values().removeIf(attack -> now >= attack.getLast() + ATTACK_RETENTION_TIME);
-        
         // Tick behavior when it is ready
         if(now >= lastBehavedAt + (int)(1000 / speed)) {
             lastBehavedAt = now;
@@ -146,37 +156,17 @@ public class Npc extends Entity {
     }
     
     @Override
-    public void die(Player killer) {
-        // Grant loot & track kill
-        if(killer != null) {
-            if(!isPlayerPlaced()) {
-                // Track assists
-                for(Player attacker : recentAttacks.keySet()) {
-                    if(attacker != killer) {
-                        attacker.getStatistics().trackAssist(config);
-                    }
-                }
-                
-                killer.getStatistics().trackKill(config);
-            }
-            
-            EntityLoot loot = getRandomLoot(killer);
-            
-            if(loot != null) {
-                Item item = loot.getItem();
-                
-                if(!item.isAir()) {
-                    killer.getInventory().addItem(item, loot.getQuantity(), true);
-                }
-            }
-        }
-        
+    public void die(EntityAttack cause) {        
         // Remove itself from the guard block metadata if it was guarding one
         if(isGuard()) {
             MetaBlock metaBlock = zone.getMetaBlock(guardBlock.getX(), guardBlock.getY());
             
             if(metaBlock != null) {
-                MapHelper.getList(metaBlock.getMetadata(), "!", Collections.emptyList()).remove(typeName);
+                List<String> guards = MapHelper.getList(metaBlock.getMetadata(), "!");
+                
+                if(guards != null) {
+                    guards.remove(typeName);
+                }
             }
         }
         
@@ -184,6 +174,41 @@ public class Npc extends Entity {
         if(isMounted()) {
             zone.updateBlock(mountBlock.getX(), mountBlock.getY(), Layer.FRONT, 0);
         }
+        
+        // Do nothing else if cause data isn't present
+        if(cause == null) {
+            return;
+        }
+        
+        Entity killer = cause.getAttacker();
+        
+        // Grant loot & track kill
+        if(!artificial && killer != null && killer.isPlayer()) {
+            Player player = (Player)killer;
+            
+            if(!isPlayerPlaced()) {
+                // Track assists
+                for(EntityAttack recentAttack : recentAttacks) {
+                    Entity attacker = recentAttack.getAttacker();
+                    
+                    if(attacker != player && attacker.isPlayer()) {
+                        ((Player)attacker).getStatistics().trackAssist(config);
+                    }
+                }
+                
+                player.getStatistics().trackKill(config);
+            }
+            
+            EntityLoot loot = getRandomLoot(player, cause.getWeapon());
+            
+            if(loot != null) {
+                Item item = loot.getItem();
+                
+                if(!item.isAir()) {
+                    player.getInventory().addItem(item, loot.getQuantity(), true);
+                }
+            }
+        }
     }
     
     @Override
@@ -191,6 +216,20 @@ public class Npc extends Entity {
         return maxHealth;
     }
     
+    @Override
+    public float getDefense(EntityAttack attack) {
+        Entity attacker = attack.getAttacker();
+        Player player = attacker != null && attacker.isPlayer() ? (Player)attacker : null;
+        
+        // Full defense if block is mounted and is protected
+        if(isMounted() && zone.isBlockProtected(mountBlock.getX(), mountBlock.getY(), player)) {
+            return 1.0F;
+        }
+        
+        // Otherwise, calculate defense
+        return getBaseDefense(attack.getDamageType()) + activeDefenses.getOrDefault(attack.getDamageType(), 0F);
+    }
+    
     @Override
     public Map<String, Object> getStatusConfig() {
         Map<String, Object> config = super.getStatusConfig();
@@ -229,37 +268,6 @@ public class Npc extends Entity {
         return config;
     }
     
-    public Vector2i getSize() {
-        return size;
-    }
-    
-    public void attack(Player attacker, Item weapon) {
-        // Prevent damage if this entity is mounted and its mount is protected
-        if(!attacker.isGodMode() && isMounted() && zone.isBlockProtected(mountBlock.getX(), mountBlock.getY(), attacker)) {
-            return;
-        }
-        
-        Pair<Item, Long> recentAttack = recentAttacks.get(attacker);
-        long now = System.currentTimeMillis();
-        
-        // Reject the attack if the player already attacked this entity recently
-        if(!attacker.isGodMode() && recentAttack != null && now < recentAttack.getLast() + ATTACK_INVINCIBLE_TIME) {
-            return;
-        }
-        
-        float damage = attacker.isGodMode() ? 9999 : calculateDamage(weapon.getDamage(), weapon.getDamageType());
-        damage(damage, attacker);
-        recentAttacks.put(attacker, new Pair<>(weapon, now));
-    }
-    
-    public float calculateDamage(float baseDamage, DamageType type) {
-        return baseDamage * (1 - getDefense(type));
-    }
-    
-    public Collection<Pair<Item, Long>> getRecentAttacks() {
-        return Collections.unmodifiableCollection(recentAttacks.values());
-    }
-    
     public void setDefense(DamageType type, float amount) {
         if(amount == 0) {
             activeDefenses.remove(type);
@@ -268,21 +276,11 @@ public class Npc extends Entity {
         }
     }
     
-    public float getDefense(DamageType type) {
-        return getDefense(type, true);
-    }
-    
-    public float getDefense(DamageType type, boolean includeBaseDefense) {
-        return (includeBaseDefense ? getBaseDefense(type) : 0) + activeDefenses.getOrDefault(type, 0F);
-    }
-    
     public boolean isTransient() {
-        return !isGuard() && !isMounted();
+        return !isGuard() && !isMounted() && !persist;
     }
     
-    public EntityLoot getRandomLoot(Player awardee) {
-        Item weapon = awardee.getHeldItem();
-        
+    public EntityLoot getRandomLoot(Player awardee, Item weapon) {        
         if(isOwnedBy(awardee)) {
             return placedLoot.next();
         } else if(lootByWeapon.containsKey(weapon)) {
@@ -372,6 +370,18 @@ public class Npc extends Entity {
         return target;
     }
     
+    public void setArtificial(boolean artificial) {
+        this.artificial = artificial;
+    }
+    
+    public boolean isArtificial() {
+        return artificial;
+    }
+    
+    public boolean isPersistent() {
+        return persist;
+    }
+    
     public void setSpeed(float speed) {
         this.speed = speed;
     }
@@ -403,15 +413,15 @@ public class Npc extends Entity {
         int tY = y + oY;
         boolean blocked = zone.isBlockSolid(tX, tY) || (oX != 0 && zone.isBlockSolid(tX, y)) || (oY != 0 && zone.isBlockSolid(x, tY));
         
-        if(size.getX() > 1) {
-            int additionalWidth = size.getX() - 1;
+        if(sizeX > 1) {
+            int additionalWidth = sizeX - 1;
             blocked = blocked || zone.isBlockSolid(tX + additionalWidth, tY) 
                     || (oX != 0 && zone.isBlockSolid(tX + additionalWidth, y)) 
                     || (oY != 0 && zone.isBlockSolid(x + additionalWidth, tY));
         }
         
-        if(size.getY() > 1) {
-            int additionalHeight = size.getY() - 1;
+        if(sizeY > 1) {
+            int additionalHeight = sizeY - 1;
             blocked = blocked || zone.isBlockSolid(tX, tY - additionalHeight) 
                     || (oX != 0 && zone.isBlockSolid(tX, y - additionalHeight)) 
                     || (oY != 0 && zone.isBlockSolid(x, tY - additionalHeight));
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java
new file mode 100644
index 0000000..1d17330
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java
@@ -0,0 +1,47 @@
+package brainwine.gameserver.entity.npc;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import brainwine.gameserver.entity.EntityConfig;
+
+/**
+ * Storage data for persistent non-player characters.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class NpcData {
+    
+    private EntityConfig type;
+    private String name;
+    private int x;
+    private int y;
+    
+    @JsonCreator
+    public NpcData(@JsonProperty(value = "type", required = true) EntityConfig type) {
+        this.type = type;
+    }
+    
+    public NpcData(Npc npc) {
+        this.type = npc.getConfig();
+        this.name = npc.getName();
+        this.x = npc.getBlockX();
+        this.y = npc.getBlockY();
+    }
+    
+    public EntityConfig getType() {
+        return type;
+    }
+    
+    public String getName() {
+        return name;
+    }
+    
+    public int getX() {
+        return x;
+    }
+    
+    public int getY() {
+        return y;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/Behavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java
similarity index 61%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/Behavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java
index 610f801..36fe0cd 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/Behavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior;
+package brainwine.gameserver.entity.npc.behavior;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
@@ -7,26 +7,26 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
 import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
 
-import brainwine.gameserver.behavior.composed.CrawlerBehavior;
-import brainwine.gameserver.behavior.composed.DiggerBehavior;
-import brainwine.gameserver.behavior.composed.FlyerBehavior;
-import brainwine.gameserver.behavior.composed.WalkerBehavior;
-import brainwine.gameserver.behavior.parts.ClimbBehavior;
-import brainwine.gameserver.behavior.parts.DigBehavior;
-import brainwine.gameserver.behavior.parts.EruptionAttackBehavior;
-import brainwine.gameserver.behavior.parts.FallBehavior;
-import brainwine.gameserver.behavior.parts.FlyBehavior;
-import brainwine.gameserver.behavior.parts.FlyTowardBehavior;
-import brainwine.gameserver.behavior.parts.FollowBehavior;
-import brainwine.gameserver.behavior.parts.IdleBehavior;
-import brainwine.gameserver.behavior.parts.RandomlyTargetBehavior;
-import brainwine.gameserver.behavior.parts.ReporterBehavior;
-import brainwine.gameserver.behavior.parts.ShielderBehavior;
-import brainwine.gameserver.behavior.parts.SpawnAttackBehavior;
-import brainwine.gameserver.behavior.parts.TurnBehavior;
-import brainwine.gameserver.behavior.parts.UnblockBehavior;
-import brainwine.gameserver.behavior.parts.WalkBehavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.composed.CrawlerBehavior;
+import brainwine.gameserver.entity.npc.behavior.composed.DiggerBehavior;
+import brainwine.gameserver.entity.npc.behavior.composed.FlyerBehavior;
+import brainwine.gameserver.entity.npc.behavior.composed.WalkerBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.ClimbBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.DigBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.EruptionAttackBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FallBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FlyBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FlyTowardBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FollowBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.RandomlyTargetBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.ReporterBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.ShielderBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.SpawnAttackBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.TurnBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.UnblockBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.WalkBehavior;
 
 /**
  * Heavily based on Deepworld's original "rubyhave" (ha ha very punny) behavior system.
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/CompositeBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/CompositeBehavior.java
similarity index 97%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/CompositeBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/CompositeBehavior.java
index 2f56955..ef07508 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/CompositeBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/CompositeBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior;
+package brainwine.gameserver.entity.npc.behavior;
 
 import static brainwine.shared.LogMarkers.SERVER_MARKER;
 
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/SelectorBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java
similarity index 91%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/SelectorBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java
index bf1025a..85bbbb6 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/SelectorBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior;
+package brainwine.gameserver.entity.npc.behavior;
 
 import java.util.Map;
 
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/SequenceBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java
similarity index 97%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/SequenceBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java
index 9bc5890..0999021 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/SequenceBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior;
+package brainwine.gameserver.entity.npc.behavior;
 
 import static brainwine.shared.LogMarkers.SERVER_MARKER;
 
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/CrawlerBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/CrawlerBehavior.java
similarity index 67%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/composed/CrawlerBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/CrawlerBehavior.java
index 53575bc..4c2821d 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/CrawlerBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/CrawlerBehavior.java
@@ -1,17 +1,17 @@
-package brainwine.gameserver.behavior.composed;
+package brainwine.gameserver.entity.npc.behavior.composed;
 
 import java.util.Map;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.SelectorBehavior;
-import brainwine.gameserver.behavior.parts.ClimbBehavior;
-import brainwine.gameserver.behavior.parts.FallBehavior;
-import brainwine.gameserver.behavior.parts.IdleBehavior;
-import brainwine.gameserver.behavior.parts.TurnBehavior;
-import brainwine.gameserver.behavior.parts.WalkBehavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.SelectorBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.ClimbBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FallBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.TurnBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.WalkBehavior;
 import brainwine.gameserver.util.MapHelper;
 
 public class CrawlerBehavior extends SelectorBehavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/DiggerBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/DiggerBehavior.java
similarity index 66%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/composed/DiggerBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/DiggerBehavior.java
index d6fb917..bf95bf7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/DiggerBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/DiggerBehavior.java
@@ -1,17 +1,17 @@
-package brainwine.gameserver.behavior.composed;
+package brainwine.gameserver.entity.npc.behavior.composed;
 
 import java.util.Map;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.SelectorBehavior;
-import brainwine.gameserver.behavior.parts.DigBehavior;
-import brainwine.gameserver.behavior.parts.FallBehavior;
-import brainwine.gameserver.behavior.parts.IdleBehavior;
-import brainwine.gameserver.behavior.parts.TurnBehavior;
-import brainwine.gameserver.behavior.parts.WalkBehavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.SelectorBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.DigBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FallBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.TurnBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.WalkBehavior;
 import brainwine.gameserver.util.MapHelper;
 
 public class DiggerBehavior extends SelectorBehavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/FlyerBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/FlyerBehavior.java
similarity index 71%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/composed/FlyerBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/FlyerBehavior.java
index d511c6f..1f5b60d 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/FlyerBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/FlyerBehavior.java
@@ -1,15 +1,15 @@
-package brainwine.gameserver.behavior.composed;
+package brainwine.gameserver.entity.npc.behavior.composed;
 
 import java.util.Map;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.SelectorBehavior;
-import brainwine.gameserver.behavior.parts.FlyBehavior;
-import brainwine.gameserver.behavior.parts.FlyTowardBehavior;
-import brainwine.gameserver.behavior.parts.IdleBehavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.SelectorBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FlyBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FlyTowardBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior;
 import brainwine.gameserver.util.MapHelper;
 
 public class FlyerBehavior extends SelectorBehavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/WalkerBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/WalkerBehavior.java
similarity index 68%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/composed/WalkerBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/WalkerBehavior.java
index 4ad4a9a..15eb95d 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/composed/WalkerBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/WalkerBehavior.java
@@ -1,16 +1,16 @@
-package brainwine.gameserver.behavior.composed;
+package brainwine.gameserver.entity.npc.behavior.composed;
 
 import java.util.Map;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.SelectorBehavior;
-import brainwine.gameserver.behavior.parts.FallBehavior;
-import brainwine.gameserver.behavior.parts.IdleBehavior;
-import brainwine.gameserver.behavior.parts.TurnBehavior;
-import brainwine.gameserver.behavior.parts.WalkBehavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.SelectorBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.FallBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.TurnBehavior;
+import brainwine.gameserver.entity.npc.behavior.parts.WalkBehavior;
 import brainwine.gameserver.util.MapHelper;
 
 public class WalkerBehavior extends SelectorBehavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/ClimbBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ClimbBehavior.java
similarity index 90%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/ClimbBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ClimbBehavior.java
index dc86bc9..9b3c7cb 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/ClimbBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ClimbBehavior.java
@@ -1,11 +1,11 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.FacingDirection;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 
 public class ClimbBehavior extends Behavior {
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/DigBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/DigBehavior.java
similarity index 86%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/DigBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/DigBehavior.java
index ab133f9..109bf24 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/DigBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/DigBehavior.java
@@ -1,10 +1,10 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.zone.Zone;
 
 public class DigBehavior extends Behavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/EruptionAttackBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/EruptionAttackBehavior.java
similarity index 96%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/EruptionAttackBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/EruptionAttackBehavior.java
index a21ff18..fa39697 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/EruptionAttackBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/EruptionAttackBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import java.util.Map;
 
@@ -6,10 +6,10 @@ import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonSetter;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.EntityConfig;
 import brainwine.gameserver.entity.EntityStatus;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.server.messages.EntityStatusMessage;
 import brainwine.gameserver.util.MapHelper;
 import brainwine.gameserver.util.Vector2i;
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FallBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FallBehavior.java
similarity index 87%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/FallBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FallBehavior.java
index 686f1e5..3e713eb 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FallBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FallBehavior.java
@@ -1,11 +1,11 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonAlias;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 
 public class FallBehavior extends Behavior {
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FlyBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FlyBehavior.java
similarity index 94%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/FlyBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FlyBehavior.java
index 2067687..f8594f3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FlyBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FlyBehavior.java
@@ -1,12 +1,12 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import java.util.concurrent.ThreadLocalRandom;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.util.Vector2i;
 
 public class FlyBehavior extends Behavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FlyTowardBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FlyTowardBehavior.java
similarity index 95%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/FlyTowardBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FlyTowardBehavior.java
index baa0a9d..1f60e15 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FlyTowardBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FlyTowardBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FollowBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FollowBehavior.java
similarity index 84%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/FollowBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FollowBehavior.java
index 087b004..ecbee08 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/FollowBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/FollowBehavior.java
@@ -1,11 +1,11 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.FacingDirection;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 
 public class FollowBehavior extends Behavior {
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/IdleBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/IdleBehavior.java
similarity index 96%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/IdleBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/IdleBehavior.java
index 68112f6..d9f9597 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/IdleBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/IdleBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import java.util.Random;
 import java.util.concurrent.ThreadLocalRandom;
@@ -7,9 +7,9 @@ import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonSetter;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.FacingDirection;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.util.Vector2i;
 
 public class IdleBehavior extends Behavior {
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/RandomlyTargetBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/RandomlyTargetBehavior.java
similarity index 87%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/RandomlyTargetBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/RandomlyTargetBehavior.java
index 3311393..0466adb 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/RandomlyTargetBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/RandomlyTargetBehavior.java
@@ -1,10 +1,10 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.entity.player.Player;
 
 public class RandomlyTargetBehavior extends Behavior {
@@ -31,7 +31,7 @@ public class RandomlyTargetBehavior extends Behavior {
         if(!entity.hasTarget()) {
             Player target = entity.getZone().getRandomPlayerInRange(entity.getX(), entity.getY(), range);
             
-            if(target != null && !target.isGodMode() && !target.isDead() && !entity.isOwnedBy(target) && (!blockable || entity.canSee(target))) {
+            if(target != null && !target.isGodMode() && !target.isStealthy() && !target.isDead() && !entity.isOwnedBy(target) && (!blockable || entity.canSee(target))) {
                 entity.setTarget(target);
                 targetLockedAt = now;
             }
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/ReporterBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ReporterBehavior.java
similarity index 80%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/ReporterBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ReporterBehavior.java
index c77fc7e..a1beaf8 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/ReporterBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ReporterBehavior.java
@@ -1,10 +1,10 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 
 public class ReporterBehavior extends Behavior {
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/ShielderBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ShielderBehavior.java
similarity index 87%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/ShielderBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ShielderBehavior.java
index 0a0de4e..664fd2c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/ShielderBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ShielderBehavior.java
@@ -1,7 +1,6 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ThreadLocalRandom;
@@ -9,11 +8,10 @@ import java.util.concurrent.ThreadLocalRandom;
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
+import brainwine.gameserver.entity.EntityAttack;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.item.DamageType;
-import brainwine.gameserver.item.Item;
-import brainwine.gameserver.util.Pair;
 
 public class ShielderBehavior extends Behavior {
     
@@ -32,11 +30,12 @@ public class ShielderBehavior extends Behavior {
     @Override
     public boolean behave() {
         long now = System.currentTimeMillis();
-        Collection<Pair<Item, Long>> recentAttacks = entity.getRecentAttacks();
+        EntityAttack attack = entity.getMostRecentAttack();
         
-        if(!recentAttacks.isEmpty()) {
+        if(attack != null) {
             lastAttackedAt = now;
-            DamageType type = recentAttacks.stream().findFirst().get().getFirst().getDamageType();
+            DamageType type = attack.getDamageType();
+            
             if(currentShield == null && now >= shieldStart + (recharge * 1000)) {
                 if(defenses.contains(type)) {
                     setShield(type);
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/SpawnAttackBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/SpawnAttackBehavior.java
similarity index 90%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/SpawnAttackBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/SpawnAttackBehavior.java
index 6650737..9871ad3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/SpawnAttackBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/SpawnAttackBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import java.util.Map;
 
@@ -6,13 +6,12 @@ import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonSetter;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.EntityConfig;
 import brainwine.gameserver.entity.EntityStatus;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.server.messages.EntityStatusMessage;
 import brainwine.gameserver.util.MapHelper;
-import brainwine.gameserver.util.Vector2i;
 
 public class SpawnAttackBehavior extends Behavior {
     
@@ -41,9 +40,8 @@ public class SpawnAttackBehavior extends Behavior {
         
         if(npc) {
             // Spawn child at parent's location
-            Vector2i size = entity.getSize();
-            int spawnX = (int)(entity.getX() + (size.getX() / 2F));
-            int spawnY = (int)(entity.getY() + (size.getY() / 2F));
+            int spawnX = (int)(entity.getX() + (entity.getSizeX() / 2.0F));
+            int spawnY = (int)(entity.getY() + (entity.getSizeX() / 2.0F));
             Npc child = new Npc(entity.getZone(), entityConfig);
             child.setOwner(entity);
             entity.addChild(child);
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/TurnBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/TurnBehavior.java
similarity index 85%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/TurnBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/TurnBehavior.java
index 8ae0134..9e2fcf0 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/TurnBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/TurnBehavior.java
@@ -1,11 +1,11 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.FacingDirection;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 
 public class TurnBehavior extends Behavior {
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/UnblockBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/UnblockBehavior.java
similarity index 74%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/UnblockBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/UnblockBehavior.java
index 929b0dc..8c40083 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/UnblockBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/UnblockBehavior.java
@@ -1,4 +1,4 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import java.util.Random;
 import java.util.concurrent.ThreadLocalRandom;
@@ -6,9 +6,8 @@ import java.util.concurrent.ThreadLocalRandom;
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.npc.Npc;
-import brainwine.gameserver.util.Vector2i;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 import brainwine.gameserver.zone.Zone;
 
 public class UnblockBehavior extends Behavior {
@@ -23,12 +22,11 @@ public class UnblockBehavior extends Behavior {
     @Override
     public boolean behave() {
         Zone zone = entity.getZone();
-        Vector2i size = entity.getSize();
         Random random = ThreadLocalRandom.current();
         
         for(int i = 0; i < rate; i++) {
-            int x = (int)entity.getX() + random.nextInt(size.getX());
-            int y = (int)entity.getY() - random.nextInt(size.getY());
+            int x = (int)entity.getX() + random.nextInt(entity.getSizeX());
+            int y = (int)entity.getY() - random.nextInt(entity.getSizeY());
             
             if(zone.isChunkLoaded(x, y) && zone.getBlock(x, y).getFrontItem().isDiggable()) {
                 zone.digBlock(x, y);
diff --git a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/WalkBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/WalkBehavior.java
similarity index 89%
rename from gameserver/src/main/java/brainwine/gameserver/behavior/parts/WalkBehavior.java
rename to gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/WalkBehavior.java
index 8e746ee..62e23dd 100644
--- a/gameserver/src/main/java/brainwine/gameserver/behavior/parts/WalkBehavior.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/WalkBehavior.java
@@ -1,12 +1,12 @@
-package brainwine.gameserver.behavior.parts;
+package brainwine.gameserver.entity.npc.behavior.parts;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.fasterxml.jackson.annotation.JsonAlias;
 import com.fasterxml.jackson.annotation.JsonCreator;
 
-import brainwine.gameserver.behavior.Behavior;
 import brainwine.gameserver.entity.FacingDirection;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.behavior.Behavior;
 
 public class WalkBehavior extends Behavior {
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/Appearance.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/Appearance.java
new file mode 100644
index 0000000..34684de
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/Appearance.java
@@ -0,0 +1,87 @@
+package brainwine.gameserver.entity.player;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import brainwine.gameserver.GameConfiguration;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemRegistry;
+import brainwine.gameserver.util.MapHelper;
+
+/**
+ * Utility class for player appearance related stuff.
+ * Ghosts also have a random appearance, which is why it's here instead of in the player class.
+ */
+public class Appearance {
+    
+    public static Map<String, Object> getRandomAppearance() {
+        return getRandomAppearance(null);
+    }
+    
+    public static Map<String, Object> getRandomAppearance(Player player) {
+        Map<String, Object> appearance = new HashMap<>();
+        
+        for(AppearanceSlot slot : AppearanceSlot.values()) {
+            // Skip if slot cannot be changed by players
+            if(!slot.isChangeable()) {
+                continue;
+            }
+            
+            String category = slot.getCategory();
+            
+            // Color handling
+            if(slot.isColor()) {
+                List<String> colors = getAvailableColors(slot, player);
+                
+                // Change appearance to random color
+                if(!colors.isEmpty()) {
+                    appearance.put(slot.getId(), colors.get((int)(Math.random() * colors.size())));
+                }
+                
+                continue;
+            }
+            
+            // Fetch list of items in this slot's category that the player owns
+            List<Item> items = ItemRegistry.getItemsByCategory(category).stream()
+                    .filter(item -> item.isBase() || (player != null && player.getInventory().hasItem(item)))
+                    .collect(Collectors.toList());
+            
+            // Change appearance to random clothing item
+            if(!items.isEmpty()) {
+                appearance.put(slot.getId(), items.get((int)(Math.random() * items.size())).getCode());
+            }
+        }
+        
+        return appearance;
+    }
+    
+    public static List<String> getAvailableColors(AppearanceSlot slot) {
+        return getAvailableColors(null);
+    }
+    
+    public static List<String> getAvailableColors(AppearanceSlot slot, Player player) {
+        List<String> colors = new ArrayList<>();
+        
+        // Return empty list if slot is not valid
+        if(!slot.isColor()) {
+            return colors;
+        }
+        
+        Map<String, Object> wardrobe = MapHelper.getMap(GameConfiguration.getBaseConfig(), "wardrobe", Collections.emptyMap());
+        String category = slot.getCategory();
+        
+        // Add base colors
+        colors.addAll(MapHelper.getList(wardrobe, category, Collections.emptyList()));
+        
+        // Add bonus colors
+        if(player != null && player.getInventory().hasItem(ItemRegistry.getItem("accessories/makeup"))) {
+            colors.addAll(MapHelper.getList(wardrobe, String.format("%s-bonus", category), Collections.emptyList()));
+        }
+        
+        return colors;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/AppearanceSlot.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/AppearanceSlot.java
new file mode 100644
index 0000000..43ed761
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/AppearanceSlot.java
@@ -0,0 +1,61 @@
+package brainwine.gameserver.entity.player;
+
+public enum AppearanceSlot {
+
+    SKIN_COLOR("c*", "skin-color", true),
+    HAIR_COLOR("h*", "hair-color", true),
+    HAIR("h", "hair", true),
+    FACIAL_HAIR("fh", "facialhair", true),
+    TOPS("t", "tops", true),
+    BOTTOMS("b", "bottoms", true),
+    FOOTWEAR("fw", "footwear", true),
+    HEADGEAR("hg", "headgear", true),
+    FACIAL_GEAR("fg", "facialgear", true),
+    FACIAL_GEAR_GLOW("fg*", "facialgear-glow"),
+    SUIT("u", "suit"),
+    TOPS_OVERLAY("to", "tops-overlay"),
+    TOPS_OVERLAY_GLOW("to*", "tops-overlay-glow"),
+    ARMS_OVERLAY("ao", "arms-overlay"),
+    LEGS_OVERLAY("lo", "legs-overlay"),
+    FOOTWEAR_OVERLAY("fo", "footwear-overlay");
+    
+    private final String id;
+    private final String category;
+    private final boolean changeable;
+
+    private AppearanceSlot(String id, String category) {
+        this(id, category, false);
+    }
+    
+    private AppearanceSlot(String id, String category, boolean changeable) {
+        this.id = id;
+        this.category = category;
+        this.changeable = changeable;
+    }
+    
+    public static AppearanceSlot fromId(String id) {
+        for(AppearanceSlot value : values()) {
+            if(value.getId().equals(id)) {
+                return value;
+            }
+        }
+        
+        return null;
+    }
+    
+    public String getId() {
+        return id;
+    }
+    
+    public String getCategory() {
+        return category;
+    }
+    
+    public boolean isChangeable() {
+        return changeable;
+    }
+    
+    public boolean isColor() {
+        return id.endsWith("*");
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/ClothingSlot.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/ClothingSlot.java
deleted file mode 100644
index f0ab70b..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/ClothingSlot.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package brainwine.gameserver.entity.player;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonValue;
-
-public enum ClothingSlot {
-    
-    HAIR("h"),
-    FACIAL_HAIR("fh"),
-    TOPS("t"),
-    BOTTOMS("b"),
-    FOOTWEAR("fw"),
-    HEADGEAR("hg"),
-    FACIAL_GEAR("fg"),
-    SUIT("u"),
-    TOPS_OVERLAY("to"),
-    ARMS_OVERLAY("ao"),
-    LEGS_OVERLAY("lo"),
-    FOOTWEAR_OVERLAY("fo");
-    
-    private final String id;
-    
-    private ClothingSlot(String id) {
-        this.id = id;
-    }
-    
-    @JsonCreator
-    public static ClothingSlot fromId(String id) {
-        for(ClothingSlot value : values()) {
-            if(value.getId().equals(id)) {
-                return value;
-            }
-        }
-        
-        return null;
-    }
-    
-    @JsonValue
-    public String getId() {
-        return id;
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/ColorSlot.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/ColorSlot.java
deleted file mode 100644
index 8fe4caa..0000000
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/ColorSlot.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package brainwine.gameserver.entity.player;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonValue;
-
-public enum ColorSlot {
-    
-    SKIN_COLOR("c*"),
-    HAIR_COLOR("h*");
-    
-    private final String id;
-    
-    private ColorSlot(String id) {
-        this.id = id;
-    }
-    
-    @JsonCreator
-    public static ColorSlot fromId(String id) {
-        for(ColorSlot value : values()) {
-            if(value.getId().equals(id)) {
-                return value;
-            }
-        }
-        
-        return null;
-    }
-    
-    @JsonValue
-    public String getId() {
-        return id;
-    }
-}
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/NameChange.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/NameChange.java
new file mode 100644
index 0000000..5252cda
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/NameChange.java
@@ -0,0 +1,35 @@
+package brainwine.gameserver.entity.player;
+
+import java.time.OffsetDateTime;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIncludeProperties;
+
+@JsonIncludeProperties({"new_name", "previous_name", "date"})
+public class NameChange {
+    
+    private String newName;
+    private String previousName;
+    private OffsetDateTime date;
+    
+    @JsonCreator
+    private NameChange() {}
+    
+    public NameChange(String newName, String previousName) {
+        this.newName = newName;
+        this.previousName = previousName;
+        date = OffsetDateTime.now();
+    }
+    
+    public String getNewName() {
+        return newName;
+    }
+    
+    public String getPreviousName() {
+        return previousName;
+    }
+    
+    public OffsetDateTime getDate() {
+        return date;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/NotificationType.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/NotificationType.java
index aaeebc1..a6126f1 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/NotificationType.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/NotificationType.java
@@ -13,6 +13,7 @@ public enum NotificationType {
     ACCOMPLISHMENT(10),
     PEER_ACCOMPLISHMENT(11),
     REWARD(12), // v2 only
+    NOTE(13), // v2 only
     CHAT(20),
     LEVEL_UP(21), // v3 only
     ACHIEVEMENT(22), // v3 only
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/Player.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/Player.java
index dd55321..97536a3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/Player.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/Player.java
@@ -10,7 +10,6 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
@@ -19,24 +18,25 @@ import com.fasterxml.jackson.annotation.JsonCreator;
 
 import brainwine.gameserver.GameConfiguration;
 import brainwine.gameserver.GameServer;
+import brainwine.gameserver.Timer;
 import brainwine.gameserver.achievements.Achievement;
 import brainwine.gameserver.achievements.AchievementManager;
 import brainwine.gameserver.achievements.JourneymanAchievement;
-import brainwine.gameserver.command.CommandExecutor;
+import brainwine.gameserver.commands.CommandExecutor;
 import brainwine.gameserver.dialog.Dialog;
 import brainwine.gameserver.dialog.DialogListItem;
 import brainwine.gameserver.dialog.DialogSection;
 import brainwine.gameserver.dialog.DialogType;
 import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.EntityAttack;
 import brainwine.gameserver.entity.EntityStatus;
-import brainwine.gameserver.entity.FacingDirection;
 import brainwine.gameserver.entity.npc.Npc;
-import brainwine.gameserver.item.Action;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.ItemRegistry;
 import brainwine.gameserver.item.ItemUseType;
 import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.item.MiningBonus;
+import brainwine.gameserver.item.consumables.Consumable;
 import brainwine.gameserver.loot.Loot;
 import brainwine.gameserver.server.Message;
 import brainwine.gameserver.server.messages.AchievementMessage;
@@ -66,7 +66,7 @@ import brainwine.gameserver.server.models.EntityStatusData;
 import brainwine.gameserver.server.pipeline.Connection;
 import brainwine.gameserver.util.MapHelper;
 import brainwine.gameserver.util.MathUtils;
-import brainwine.gameserver.util.Vector2i;
+import brainwine.gameserver.util.VersionUtils;
 import brainwine.gameserver.zone.Chunk;
 import brainwine.gameserver.zone.MetaBlock;
 import brainwine.gameserver.zone.Zone;
@@ -95,24 +95,30 @@ public class Player extends Entity implements CommandExecutor {
     private Inventory inventory;
     private PlayerStatistics statistics;
     private List<String> authTokens;
+    private List<NameChange> nameChanges;
     private List<PlayerRestriction> mutes;
     private List<PlayerRestriction> bans;
+    private Set<String> lootCodes;
     private Set<Achievement> achievements;
     private Map<String, Float> ignoredHints;
     private Map<Skill, Integer> skills;
-    private Map<ClothingSlot, Item> equippedClothing;
-    private Map<ColorSlot, String> equippedColors;
+    private Map<Item, List<Skill>> bumpedSkills;
+    private Map<String, Object> appearance;
     private final Map<String, Object> settings = new HashMap<>();
     private final Set<Integer> activeChunks = new HashSet<>();
     private final Map<Integer, Consumer<Object[]>> dialogs = new HashMap<>();
+    private final List<Timer<String>> timers = new ArrayList<>();
     private final List<Entity> trackedEntities = new ArrayList<>();
     private String clientVersion;
     private Placement lastPlacement;
     private Item heldItem = Item.AIR;
-    private Vector2i spawnPoint = new Vector2i(0, 0);
+    private int spawnX;
+    private int spawnY;
     private int teleportX;
     private int teleportY;
+    private boolean stealth;
     private boolean godMode;
+    private boolean customSpawn;
     private long lastHeartbeat;
     private long lastTrackedEntityUpdate;
     private Zone nextZone;
@@ -132,13 +138,15 @@ public class Player extends Entity implements CommandExecutor {
         this.inventory = config.getInventory();
         this.statistics = config.getStatistics();
         this.authTokens = config.getAuthTokens();
+        this.nameChanges = config.getNameChanges();
         this.mutes = config.getMutes();
         this.bans = config.getBans();
+        this.lootCodes = config.getLootCodes();
         this.achievements = config.getAchievements();
         this.ignoredHints = config.getIgnoredHints();
         this.skills = config.getSkills();
-        this.equippedClothing = config.getEquippedClothing();
-        this.equippedColors = config.getEquippedColors();
+        this.bumpedSkills = config.getBumpedSkills();
+        this.appearance = config.getAppearance();
         health = getMaxHealth();
         inventory.setPlayer(this);
         statistics.setPlayer(this);
@@ -151,13 +159,15 @@ public class Player extends Entity implements CommandExecutor {
         this.inventory = new Inventory(this);
         this.statistics = new PlayerStatistics(this);
         this.authTokens = new ArrayList<>();
+        this.nameChanges = new ArrayList<>();
         this.mutes = new ArrayList<>();
         this.bans = new ArrayList<>();
+        this.lootCodes = new HashSet<>();
         this.achievements = new HashSet<>();
         this.ignoredHints = new HashMap<>();
         this.skills = new HashMap<>();
-        this.equippedClothing = new HashMap<>();
-        this.equippedColors = new HashMap<>();
+        this.bumpedSkills = new HashMap<>();
+        this.appearance = Appearance.getRandomAppearance();
     }
     
     @JsonCreator
@@ -167,6 +177,7 @@ public class Player extends Entity implements CommandExecutor {
     
     @Override
     public void tick(float deltaTime) {
+        super.tick(deltaTime);
         long now = System.currentTimeMillis();
         statistics.trackPlayTime(deltaTime);
         
@@ -182,6 +193,9 @@ public class Player extends Entity implements CommandExecutor {
             heal(BASE_REGEN_AMOUNT * deltaTime);
         }
         
+        // Process timers
+        timers.removeIf(Timer::process);
+        
         // Update tracked entities
         if(now - lastTrackedEntityUpdate >= TRACKED_ENTITY_UPDATE_INTERVAL) {
             updateTrackedEntities();
@@ -191,19 +205,29 @@ public class Player extends Entity implements CommandExecutor {
     }
     
     @Override
-    public void die(Player killer) {
+    public void die(EntityAttack cause) {
+        Entity killer = cause == null ? null : cause.getAttacker();
+        String serverMessage = String.format("%s died.", name);
+        Map<String, Object> details = new HashMap<>();
+        
+        if(killer != null) {
+            details.put("<", killer.getId());
+            
+            if(killer.isPlayer()) {
+                // TODO track kill for killer achievement in pvp zones
+                serverMessage = String.format("%s killed %s.", killer.getName(), name);
+            }
+        }
+        
+        sendMessageToPeers(new EntityStatusMessage(this, EntityStatus.DEAD, details));
+        GameServer.getInstance().notify(serverMessage, NotificationType.CHAT);
         statistics.trackDeath();
-        sendMessageToPeers(new EntityStatusMessage(this, EntityStatus.DEAD)); // TODO killer id
-        GameServer.getInstance().notify(String.format("%s died", name), NotificationType.CHAT);
     }
     
     @Override
     public void notify(Object message, NotificationType type) {
-        if(type == NotificationType.SYSTEM && isV3()) {
-            sendMessage(new NotificationMessage(message, NotificationType.PEER_ACCOMPLISHMENT));
-        } else {
-            sendMessage(new NotificationMessage(message, type));
-        }
+        // TODO type SYSTEM (2) apparently plays the karma warning sound on v2 clients, so I guess we'll be mapping all of them to PEER_ACCOMPLISHMENT (11).
+        sendMessage(new NotificationMessage(message, type == NotificationType.SYSTEM ? NotificationType.PEER_ACCOMPLISHMENT : type));
     }
     
     @Override
@@ -223,6 +247,30 @@ public class Player extends Entity implements CommandExecutor {
         sendMessage(new HealthMessage(health));
     }
     
+    @Override
+    public float getAttackMultiplier(EntityAttack attack) {
+        return isGodMode() ? 9999.0F : 1.0F;
+    }
+    
+    @Override
+    public float getDefense(EntityAttack attack) {
+        return getNormalizedSkill(Skill.SURVIVAL) * 0.5F;
+    }
+    
+    @Override
+    public boolean isInvulnerable() {
+        return invulnerable || isGodMode();
+    }
+    
+    @Override
+    public void setProperties(Map<String, Object> properties, boolean sendMessage) {
+        super.setProperties(properties, sendMessage);
+        
+        if(sendMessage) {
+            sendMessage(new EntityChangeMessage(id, properties));
+        }
+    }
+    
     /**
      * @return A {@link Map} containing all the data necessary for use in {@link EntityStatusMessage}.
      */
@@ -230,7 +278,8 @@ public class Player extends Entity implements CommandExecutor {
     public Map<String, Object> getStatusConfig() {
         Map<String, Object> config = super.getStatusConfig();
         config.put("id", documentId);
-        config.putAll(getAppearanceConfig());
+        config.putAll(appearance);
+        config.put("u", inventory.findJetpack().getCode());
         return config;
     }
     
@@ -238,17 +287,29 @@ public class Player extends Entity implements CommandExecutor {
      * Called by {@link Zone#addEntity(Entity)} when the player is added to it.
      */
     public void onZoneChanged() {
-        // TODO handle spawns better
+        // Set spawn location        
+        if(customSpawn) {
+            x = spawnX;
+            y = spawnY;
+        }
+        
         MetaBlock spawn = zone.getRandomSpawnBlock();
         
         if(spawn == null) {
-            x = zone.getWidth() / 2;
-            y = 2;
+            spawnX = zone.getWidth() / 2;
+            spawnY = 2;
         } else {
-            x = spawn.getX() + 1;
-            y = spawn.getY();
+            spawnX = spawn.getX() + 1;
+            spawnY = spawn.getY();
         }
         
+        if(!customSpawn) {
+            x = spawnX;
+            y = spawnY;
+        }
+        
+        customSpawn = false;
+        
         // Set skills for new players
         for(Skill skill : Skill.values()) {
             if(!skills.containsKey(skill)) {
@@ -269,31 +330,25 @@ public class Player extends Entity implements CommandExecutor {
             inventory.moveItemToContainer(jetpack, ContainerType.ACCESSORIES, 0);
         }
         
-        spawnPoint.setX((int)x);
-        spawnPoint.setY((int)y);
         sendMessage(new ConfigurationMessage(id, getClientConfig(), GameConfiguration.getClientConfig(this), zone.getClientConfig(this)));
-        sendMessage(new ZoneStatusMessage(zone.getStatusConfig()));
-        sendMessage(new ZoneStatusMessage(zone.getStatusConfig()));
+        sendMessage(new ZoneStatusMessage(zone.getStatusConfig(this)));
         sendMessage(new PlayerPositionMessage((int)x, (int)y));
         sendMessage(new HealthMessage(health));
-        sendMessage(new InventoryMessage(inventory));
-        sendMessage(new WardrobeMessage(inventory.getWardrobe()));
-        sendMessage(new BlockMetaMessage(zone.getGlobalMetaBlocks()));
         
         // Send skill data
         for(Skill skill : skills.keySet()) {
             sendMessage(new SkillMessage(skill, skills.get(skill)));
         }
         
+        sendMessage(new InventoryMessage(inventory));
+        sendMessage(new WardrobeMessage(inventory.getWardrobe()));
+        sendMessage(new BlockMetaMessage(zone.getGlobalMetaBlocks()));
+        
         // Send peer data
         Collection<Player> peers = zone.getPlayers();
         sendMessage(new EntityStatusMessage(peers, EntityStatus.ENTERING));
         sendMessage(new EntityPositionMessage(peers));
-        
-        // TODO prepack this as well
-        for(Player peer : peers) {
-            sendMessage(new EntityItemUseMessage(peer.getId(), 0, peer.getHeldItem(), 0));
-        }
+        sendMessage(new EntityItemUseMessage(peers));
         
         // Send achievement data
         for(Achievement achievement : AchievementManager.getAchievements()) {
@@ -322,6 +377,8 @@ public class Player extends Entity implements CommandExecutor {
     
     /**
      * Called from {@link Connection} when the channel becomes inactive.
+     * 
+     * TODO Should we force process all timers on disconnect?
      */
     public void onDisconnect() {
         lastHeartbeat = 0;
@@ -393,7 +450,14 @@ public class Player extends Entity implements CommandExecutor {
     }
     
     public void changeZone(Zone zone) {
+        changeZone(zone, -1, -1);
+    }
+    
+    public void changeZone(Zone zone, int x, int y) {
         nextZone = zone;
+        spawnX = x;
+        spawnY = y;
+        customSpawn = x != -1 && y != -1;
         sendMessage(new EventMessage("playerWillChangeZone", null));
         kick("Teleporting...", true);
     }
@@ -413,20 +477,31 @@ public class Player extends Entity implements CommandExecutor {
     }
     
     public void handleDialogInput(int id, Object[] input) {
-        if(id == 0 || (input.length == 1 && input[0].equals("cancel"))) {
+        if(id == 0) {
             return;
         }
         
         Consumer<Object[]> handler = dialogs.remove(id);
         
         if(handler == null) {
-            notify("Sorry, the request has expired.");
+            if(!(input.length == 1 && input[0].equals("cancel"))) {
+                notify("Sorry, the request has expired.");
+            }
         } else {
             // TODO since we're dealing with user input, should we just try-catch this?
             handler.accept(input);
         }
     }
     
+    public void addTimer(String key, long delay, Runnable action) {
+        removeTimer(key);
+        timers.add(new Timer<>(key, delay, action));
+    }
+    
+    public void removeTimer(String key) {
+        timers.removeIf(timer -> timer.getKey().equals(key));
+    }
+    
     public void checkRegistration() {
         if(!isRegistered()) {
             sendMessage(new EventMessage("playerRegistered", false));
@@ -456,8 +531,12 @@ public class Player extends Entity implements CommandExecutor {
         return clientVersion;
     }
     
+    public boolean hasClientVersion(String version) {
+        return clientVersion != null && VersionUtils.isGreaterOrEqualTo(clientVersion, version);
+    }
+    
     public boolean isV3() {
-        return clientVersion != null && clientVersion.startsWith("3");
+        return hasClientVersion("3.0.0");
     }
     
     /**
@@ -472,11 +551,9 @@ public class Player extends Entity implements CommandExecutor {
             setHealth(getMaxHealth());
         }
         
-        int x = spawnPoint.getX();
-        int y = spawnPoint.getY();
-        sendMessage(new PlayerPositionMessage(x, y));
+        sendMessage(new PlayerPositionMessage(spawnX, spawnY));
         sendMessageToPeers(new EntityStatusMessage(this, EntityStatus.REVIVED));
-        zone.sendMessage(new EffectMessage(x, y, "spawn", 20));
+        zone.spawnEffect(spawnX, spawnY, "spawn", 20);
     }
     
     /**
@@ -491,7 +568,7 @@ public class Player extends Entity implements CommandExecutor {
         teleportY = y;
         sendMessage(new TeleportMessage(x, y));
         sendMessage(new PlayerPositionMessage(x, y));
-        zone.sendMessage(new EffectMessage(x, y, "teleport", 20));
+        zone.spawnEffect(x, y, "teleport", 20);
     }
     
     public int getTeleportX() {
@@ -502,6 +579,15 @@ public class Player extends Entity implements CommandExecutor {
         return teleportY;
     }
     
+    public void setStealth(boolean stealth) {
+        this.stealth = stealth;
+        setProperty("xs", stealth ? 1 : 0, true);
+    }
+    
+    public boolean isStealthy() {
+        return stealth;
+    }
+    
     public void setGodMode(boolean godMode) {
         this.godMode = godMode;
     }
@@ -562,6 +648,8 @@ public class Player extends Entity implements CommandExecutor {
         if(lastPlacement != null) {
             if(item.hasUse(ItemUseType.SWITCHED) && !item.hasUse(ItemUseType.SWITCH)) {
                 linked = tryLinkSwitchedItem(x, y, item);
+            } else if(item.hasUse(ItemUseType.TRANSMITTED)) {
+                linked = tryLinkTransmittedItem(x, y, item);
             }
         }
         
@@ -576,7 +664,7 @@ public class Player extends Entity implements CommandExecutor {
         Item pItem = lastPlacement.getItem();
         boolean linked = false;
         
-        if(pItem.hasUse(ItemUseType.SWITCH)) {
+        if(pItem.hasUse(ItemUseType.SWITCH, ItemUseType.TRIGGER)) {
             MetaBlock metaBlock = zone.getMetaBlock(pX, pY);
             Map<String, Object> metadata = metaBlock == null ? null : metaBlock.getMetadata();
             
@@ -599,6 +687,39 @@ public class Player extends Entity implements CommandExecutor {
         return linked;
     }
     
+    private boolean tryLinkTransmittedItem(int x, int y, Item item) {
+        int pX = lastPlacement.getX();
+        int pY = lastPlacement.getY();
+        Item pItem = lastPlacement.getItem();
+        
+        // Do nothing if the last placed item is not a transmitter
+        if(!pItem.hasUse(ItemUseType.TRANSMIT)) {
+            return false;
+        }
+        
+        int maxTransmitDistance = getTotalSkillLevel(Skill.ENGINEERING) * 10;
+        
+        // Notify the player if the distance is beyond the maximum transmit distance
+        if(!isGodMode() && !MathUtils.inRange(x, y, pX, pY, maxTransmitDistance)) {
+            notify(String.format("You can only transmit %s blocks at your current engineering level.", maxTransmitDistance));
+            return false;
+        }
+        
+        MetaBlock metaBlock = zone.getMetaBlock(pX, pY);
+        Map<String, Object> metadata = metaBlock == null ? null : metaBlock.getMetadata();
+        
+        // Do nothing if metadata is null for whatever reason
+        if(metadata == null) {
+            return false;
+        }
+        
+        // Link transmitter to beacon
+        MapHelper.appendList(metadata, ">", Arrays.asList(x, y)); // Make it a list for compatibility reasons
+        zone.updateBlock(pX, pY, Layer.FRONT, pItem, 1, null, metadata);
+        lastPlacement = null;
+        return true;
+    }
+    
     public double getMiningRange() {
         return 5 + getTotalSkillLevel(Skill.MINING) / 3.0;
     }
@@ -619,6 +740,13 @@ public class Player extends Entity implements CommandExecutor {
         return bonus.getChance() * (getTotalSkillLevel(bonus.getSkill()) / (double)MAX_SKILL_LEVEL) * heldItem.getToolBonus();
     }
     
+    /**
+     * @return The hash to be stored in blocks placed by this player.
+     */
+    public int getBlockHash() {
+        return 1 + ((documentId.hashCode() & 2047) % 2047);
+    }
+    
     public String getDocumentId() {
         return documentId;
     }
@@ -657,6 +785,26 @@ public class Player extends Entity implements CommandExecutor {
         return authTokens;
     }
     
+    public void addLootCode(String lootCode) {
+        lootCodes.add(lootCode);
+    }
+    
+    public boolean hasLootCode(String lootCode) {
+        return lootCodes.contains(lootCode);
+    }
+    
+    public Set<String> getLootCodes() {
+        return Collections.unmodifiableSet(lootCodes);
+    }
+    
+    public void trackNameChange(String newName) {
+        nameChanges.add(new NameChange(newName, name));
+    }
+    
+    public List<NameChange> getNameChanges() {
+        return nameChanges;
+    }
+    
     public void mute(String reason, OffsetDateTime until) {
         mute(null, reason, until);
     }
@@ -920,27 +1068,18 @@ public class Player extends Entity implements CommandExecutor {
         return Collections.unmodifiableSet(achievements);
     }
     
-    public void setClothing(ClothingSlot slot, Item item) {
-        if(!item.isClothing()) {
-            return;
-        }
-        
-        equippedClothing.put(slot, item);
-        zone.sendMessage(new EntityChangeMessage(id, getAppearanceConfig()));
+    public void randomizeAppearance() {
+        appearance.putAll(Appearance.getRandomAppearance(this));
+        zone.sendMessage(new EntityChangeMessage(id, appearance));
     }
     
-    public Map<ClothingSlot, Item> getEquippedClothing() {
-        return Collections.unmodifiableMap(equippedClothing);
+    public void updateAppearance(Map<String, Object> appearance) {
+        this.appearance.putAll(appearance);
+        zone.sendMessage(new EntityChangeMessage(id, appearance));
     }
     
-    public void setColor(ColorSlot slot, String hex) {
-        // TODO check if the string is actually a valid hex color
-        equippedColors.put(slot, hex);
-        zone.sendMessage(new EntityChangeMessage(id, getAppearanceConfig()));
-    }
-    
-    public Map<ColorSlot, String> getEquippedColors() {
-        return Collections.unmodifiableMap(equippedColors);
+    public Map<String, Object> getAppearance() {
+        return Collections.unmodifiableMap(appearance);
     }
     
     public void setSkillLevel(Skill skill, int level) {
@@ -964,6 +1103,10 @@ public class Player extends Entity implements CommandExecutor {
         return getSkillLevel(skill) + accessorySkillLevel;
     }
     
+    public float getNormalizedSkill(Skill skill) {
+        return getTotalSkillLevel(skill) / (float)MAX_SKILL_LEVEL;
+    }
+    
     public int getSkillLevel(Skill skill) {
         return skills.getOrDefault(skill, 1);
     }
@@ -984,19 +1127,39 @@ public class Player extends Entity implements CommandExecutor {
         return Collections.unmodifiableMap(skills);
     }
     
+    public void trackSkillBump(Item item, Skill skill) {
+        List<Skill> skills = bumpedSkills.get(item);
+        
+        if(skills == null) {
+            skills = new ArrayList<>();
+            bumpedSkills.put(item, skills);
+        }
+        
+        skills.add(skill);
+    }
+    
+    public boolean hasSkillBeenBumped(Item item, Skill skill) {
+        return bumpedSkills.getOrDefault(item, Collections.emptyList()).contains(skill);
+    }
+    
+    public Map<Item, List<Skill>> getBumpedSkills() {
+        return bumpedSkills;
+    }
+    
     public void consume(Item item) {
-        Action action = item.getAction();
+        consume(item, null);
+    }
+    
+    public void consume(Item item, Object details) {
+        Consumable consumable = item.getAction().getConsumable();
         
-        // TODO some kind of abstraction for things like this would be pretty cool
-        switch(action) {
-            case HEAL: heal(item.getPower()); break;
-            default: break;
+        if(consumable == null) {
+            sendMessage(new InventoryMessage(inventory.getClientConfig(item)));
+            notify("Sorry, this action hasn't been implemented yet.");
+            return;
         }
         
-        // (Temporary?) measure to prevent consuming unimplemented consumables
-        if(action != Action.NONE) {
-            inventory.removeItem(item);
-        }
+        consumable.consume(item, this, details);
     }
     
     public void awardLoot(Loot loot) {
@@ -1144,21 +1307,6 @@ public class Player extends Entity implements CommandExecutor {
         return connection != null && connection.isOpen();
     }
     
-    private Map<String, Object> getAppearanceConfig() {
-        Map<String, Object> appearance = new HashMap<>();
-        
-        for(Entry<ClothingSlot, Item> entry : equippedClothing.entrySet()) {
-            appearance.put(entry.getKey().getId(), entry.getValue().getCode());
-        }
-        
-        for(Entry<ColorSlot, String> entry : equippedColors.entrySet()) {
-            appearance.put(entry.getKey().getId(), entry.getValue());
-        }
-        
-        appearance.put(ClothingSlot.SUIT.getId(), inventory.findJetpack().getCode()); // Jetpack
-        return appearance;
-    }
-    
     /**
      * @return A {@link Map} containing all the data necessary for use in {@link ConfigurationMessage}.
      */
@@ -1180,7 +1328,7 @@ public class Player extends Entity implements CommandExecutor {
         config.put("items_crafted", statistics.getTotalItemsCrafted());
         config.put("play_time", (int)(statistics.getPlayTime()));
         config.put("deaths", statistics.getDeaths());
-        config.put("appearance", getAppearanceConfig());
+        config.put("appearance", appearance);
         config.put("settings", settings);
         return config;
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerConfigFile.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerConfigFile.java
index 623a321..03fac5d 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerConfigFile.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerConfigFile.java
@@ -31,13 +31,15 @@ public class PlayerConfigFile {
     private Inventory inventory = new Inventory();
     private PlayerStatistics statistics = new PlayerStatistics();
     private List<String> authTokens = new ArrayList<>();
+    private List<NameChange> nameChanges = new ArrayList<>();
     private List<PlayerRestriction> mutes = new ArrayList<>();
     private List<PlayerRestriction> bans = new ArrayList<>();
+    private Set<String> lootCodes = new HashSet<>();
     private Set<Achievement> achievements = new HashSet<>();
     private Map<String, Float> ignoredHints = new HashMap<>();
     private Map<Skill, Integer> skills = new HashMap<>();
-    private Map<ClothingSlot, Item> equippedClothing = new HashMap<>();
-    private Map<ColorSlot, String> equippedColors  = new HashMap<>();
+    private Map<Item, List<Skill>> bumpedSkills = new HashMap<>();
+    private Map<String, Object> appearance = new HashMap<>();
     
     public PlayerConfigFile(Player player) {
         this.name = player.getName();
@@ -52,13 +54,15 @@ public class PlayerConfigFile {
         this.inventory = player.getInventory();
         this.statistics = player.getStatistics();
         this.authTokens = player.getAuthTokens();
+        this.nameChanges = player.getNameChanges();
         this.mutes = player.getMutes();
         this.bans = player.getBans();
+        this.lootCodes = player.getLootCodes();
         this.achievements = player.getAchievements();
         this.ignoredHints = player.getIgnoredHints();
         this.skills = player.getSkills();
-        this.equippedClothing = player.getEquippedClothing();
-        this.equippedColors = player.getEquippedColors();
+        this.bumpedSkills = player.getBumpedSkills();
+        this.appearance = player.getAppearance();
     }
     
     @JsonCreator
@@ -90,6 +94,11 @@ public class PlayerConfigFile {
         return authTokens;
     }
     
+    @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP)
+    public List<NameChange> getNameChanges() {
+        return nameChanges;
+    }
+    
     @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP)
     public List<PlayerRestriction> getMutes() {
         return mutes;
@@ -126,6 +135,11 @@ public class PlayerConfigFile {
         return statistics;
     }
     
+    @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP)
+    public Set<String> getLootCodes() {
+        return lootCodes;
+    }
+    
     @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP)
     public Set<Achievement> getAchievements() {
         return achievements;
@@ -142,12 +156,12 @@ public class PlayerConfigFile {
     }
     
     @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP)
-    public Map<ClothingSlot, Item> getEquippedClothing() {
-        return equippedClothing;
+    public Map<Item, List<Skill>> getBumpedSkills() {
+        return bumpedSkills;
     }
     
     @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP)
-    public Map<ColorSlot, String> getEquippedColors() {
-        return equippedColors;
+    public Map<String, Object> getAppearance() {
+        return appearance;
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerManager.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerManager.java
index b6a2f94..1b4766b 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerManager.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerManager.java
@@ -21,7 +21,7 @@ import brainwine.shared.JsonHelper;
 public class PlayerManager {
     
     // TODO check platforms as well
-    public static final List<String> SUPPORTED_VERSIONS = Arrays.asList("1.12.1", "2.11.0.1", "2.11.1", "3.13.1");
+    public static final List<String> SUPPORTED_VERSIONS = Arrays.asList("1.13.3", "2.11.0.1", "2.11.1", "3.13.1");
     private static final Logger logger = LogManager.getLogger();
     private final Map<String, Player> playersById = new HashMap<>();
     private final Map<String, Player> playersByName = new HashMap<>();
@@ -139,6 +139,19 @@ public class PlayerManager {
         return false;
     }
     
+    public void changePlayerName(Player player, String name) {
+        if(playersByName.containsKey(name)) {
+            logger.warn("Tried to rename player {} to already existing name {}", player.getDocumentId(), name);
+            return;
+        }
+        
+        // Track name change and re-index the player
+        playersByName.remove(player.getName().toLowerCase());
+        player.trackNameChange(name);
+        player.setName(name);
+        playersByName.put(name.toLowerCase(), player);
+    }
+    
     public void onPlayerConnect(Player player) {
         Connection connection = player.getConnection();
         playersByConnection.put(connection, player);
diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerStatistics.java b/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerStatistics.java
index f028ad8..d628a5e 100644
--- a/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerStatistics.java
+++ b/gameserver/src/main/java/brainwine/gameserver/entity/player/PlayerStatistics.java
@@ -21,6 +21,8 @@ import brainwine.gameserver.achievements.RaiderAchievement;
 import brainwine.gameserver.achievements.ScavengingAchievement;
 import brainwine.gameserver.achievements.SidekickAchievement;
 import brainwine.gameserver.achievements.SpawnerStoppageAchievement;
+import brainwine.gameserver.achievements.TrappingAchievement;
+import brainwine.gameserver.achievements.UndertakerAchievement;
 import brainwine.gameserver.entity.EntityConfig;
 import brainwine.gameserver.item.Item;
 
@@ -33,12 +35,14 @@ public class PlayerStatistics {
     private Map<Item, Integer> discoveries = new HashMap<>();
     private Map<EntityConfig, Integer> kills = new HashMap<>();
     private Map<EntityConfig, Integer> assists = new HashMap<>();
+    private Map<EntityConfig, Integer> trappings = new HashMap<>();
     private float playTime;
     private int itemsPlaced;
     private int areasExplored;
     private int containersLooted;
     private int dungeonsRaided;
     private int mawsPlugged;
+    private int undertakings;
     private int deaths;
     
     @JsonIgnore
@@ -222,6 +226,30 @@ public class PlayerStatistics {
         return Collections.unmodifiableMap(assists);
     }
     
+    public void trackTrapping(EntityConfig entity) {
+        trappings.put(entity, getTrappings(entity) + 1);
+        player.addExperience(5);
+        player.updateAchievementProgress(TrappingAchievement.class);
+    }
+    
+    public void setTrappings(Map<EntityConfig, Integer> trappings) {
+        this.trappings = trappings;
+    }
+    
+    public int getTotalTrappings() {
+        return trappings.values().stream()
+                .reduce(Integer::sum)
+                .orElse(0);
+    }
+    
+    public int getTrappings(EntityConfig entity) {
+        return trappings.getOrDefault(entity, 0);
+    }
+    
+    public Map<EntityConfig, Integer> getTrappings() {
+        return Collections.unmodifiableMap(trappings);
+    }
+    
     public void trackPlayTime(float deltaTime) {
         playTime += deltaTime;
     }
@@ -309,6 +337,20 @@ public class PlayerStatistics {
         return mawsPlugged;
     }
     
+    public void trackUndertaking() {
+        undertakings++;
+        player.addExperience(25);
+        player.updateAchievementProgress(UndertakerAchievement.class);
+    }
+    
+    public void setUndertakings(int undertakings) {
+        this.undertakings = undertakings;
+    }
+    
+    public int getUndertakings() {
+        return undertakings;
+    }
+    
     public void trackDeath() {
         deaths++;
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/Action.java b/gameserver/src/main/java/brainwine/gameserver/item/Action.java
index 960392c..543ecee 100644
--- a/gameserver/src/main/java/brainwine/gameserver/item/Action.java
+++ b/gameserver/src/main/java/brainwine/gameserver/item/Action.java
@@ -1,13 +1,64 @@
 package brainwine.gameserver.item;
 
+import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
 
+import brainwine.gameserver.item.consumables.Consumable;
+import brainwine.gameserver.item.consumables.ConvertConsumable;
+import brainwine.gameserver.item.consumables.HealConsumable;
+import brainwine.gameserver.item.consumables.NameChangeConsumable;
+import brainwine.gameserver.item.consumables.RefillConsumable;
+import brainwine.gameserver.item.consumables.SkillConsumable;
+import brainwine.gameserver.item.consumables.SkillResetConsumable;
+import brainwine.gameserver.item.consumables.StealthConsumable;
+import brainwine.gameserver.item.consumables.TeleportConsumable;
+
+/**
+ * Action types for items.
+ * 
+ * All consumables depend on their action type, but not all items with actions are consumables.
+ * This creates a bit of an awkward situation in terms of implementation, but we're just gonna have to deal with that.
+ */
 public enum Action {
     
+    CONVERT(new ConvertConsumable()),
     DIG,
-    HEAL,
-    REFILL,
+    HEAL(new HealConsumable()),
+    NAME_CHANGE(new NameChangeConsumable()),
+    REFILL(new RefillConsumable()),
+    SKILL(new SkillConsumable()),
+    SKILL_RESET(new SkillResetConsumable()),
+    SMASH,
+    STEALTH(new StealthConsumable()),
+    TELEPORT(new TeleportConsumable()),
     
     @JsonEnumDefaultValue
     NONE;
+    
+    private final Consumable consumable;
+    
+    private Action(Consumable consumable) {
+        this.consumable = consumable;
+    }
+    
+    private Action() {
+        this(null);
+    }
+    
+    @JsonCreator
+    public static Action fromId(String id) {
+        String formatted = id.toUpperCase().replace(" ", "_");
+        
+        for(Action value : values()) {
+            if(value.toString().equals(formatted)) {
+                return value;
+            }
+        }
+        
+        return NONE;
+    }
+    
+    public Consumable getConsumable() {
+        return consumable;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/Item.java b/gameserver/src/main/java/brainwine/gameserver/item/Item.java
index fa2e36c..605dcc8 100644
--- a/gameserver/src/main/java/brainwine/gameserver/item/Item.java
+++ b/gameserver/src/main/java/brainwine/gameserver/item/Item.java
@@ -5,6 +5,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.stream.Collectors;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@@ -15,7 +16,9 @@ import brainwine.gameserver.dialog.DialogType;
 import brainwine.gameserver.entity.player.Skill;
 import brainwine.gameserver.util.Pair;
 import brainwine.gameserver.util.Vector2i;
+import brainwine.gameserver.util.WeightedMap;
 
+// TODO I don't like some parts of this, maybe they can be reworked.
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class Item {
     
@@ -27,6 +30,9 @@ public class Item {
     @JsonProperty("code")
     private int code;
     
+    @JsonProperty("category")
+    private String category;
+    
     @JsonProperty("title")
     private String title;
     
@@ -75,6 +81,9 @@ public class Item {
     @JsonProperty("power")
     private float power;
     
+    @JsonProperty("toughness")
+    private float toughness;
+    
     @JsonProperty("earthy")
     private boolean earthy;
     
@@ -93,6 +102,9 @@ public class Item {
     @JsonProperty("custom_place")
     private boolean customPlace;
     
+    @JsonProperty("field_place")
+    private boolean fieldPlace;
+    
     @JsonProperty("base")
     private boolean base;
     
@@ -111,6 +123,9 @@ public class Item {
     @JsonProperty("entity")
     private boolean entity;
     
+    @JsonProperty("steam")
+    private boolean steam;
+    
     @JsonProperty("inventory")
     private LazyItemGetter inventoryItem;
     
@@ -132,6 +147,9 @@ public class Item {
     @JsonProperty("skill_bonuses")
     private Map<Skill, Integer> skillBonuses = new HashMap<>();
     
+    @JsonProperty("power_bonus")
+    private Pair<Skill, Float> powerBonus;
+    
     @JsonProperty("mining skill")
     private Pair<Skill, Integer> miningSkill;
     
@@ -144,6 +162,15 @@ public class Item {
     @JsonProperty("damage")
     private Pair<DamageType, Float> damageInfo;
     
+    @JsonProperty("timer")
+    private Pair<String, Integer> timer;
+    
+    @JsonProperty("timer_delay")
+    private int timerDelay;
+    
+    @JsonProperty("timer_mine")
+    private boolean processTimerOnBreak;
+    
     @JsonProperty("ingredients")
     private List<CraftingRequirement> craftingIngredients = new ArrayList<>();
     
@@ -153,6 +180,12 @@ public class Item {
     @JsonProperty("use")
     private Map<ItemUseType, Object> useConfigs = new HashMap<>();
     
+    @JsonProperty("convert")
+    private Map<LazyItemGetter, LazyItemGetter> conversions = new HashMap<>();
+    
+    @JsonProperty("spawn_entity")
+    private WeightedMap<String> entitySpawns = new WeightedMap<>();
+    
     @JsonCreator
     private Item(@JsonProperty(value = "id", required = true) String id,
             @JsonProperty(value = "code", required = true) int code) {
@@ -207,6 +240,15 @@ public class Item {
         return code;
     }
     
+    public String getCategory() {
+        if(category != null) {
+            return category;
+        }
+        
+        int index = id.indexOf('/');
+        return index > 1 ? id.substring(0, index) : null;
+    }
+    
     public String getTitle() {
         return title;
     }
@@ -307,6 +349,10 @@ public class Item {
         return power;
     }
     
+    public float getToughness() {
+        return toughness;
+    }
+    
     public boolean isEarthy() {
         return earthy;
     }
@@ -335,6 +381,10 @@ public class Item {
         return customPlace;
     }
     
+    public boolean canPlaceInField() {
+        return fieldPlace;
+    }
+    
     public boolean isWhole() {
         return whole;
     }
@@ -355,10 +405,22 @@ public class Item {
         return entity;
     }
     
+    public boolean usesSteam() {
+        return steam;
+    }
+    
     public Map<Skill, Integer> getSkillBonuses() {
         return skillBonuses;
     }
     
+    public boolean hasPowerBonus() {
+        return powerBonus != null;
+    }
+    
+    public Pair<Skill, Float> getPowerBonus() {
+        return powerBonus;
+    }
+    
     public boolean requiresMiningSkill() {
         return miningSkill != null;
     }
@@ -423,6 +485,26 @@ public class Item {
         return isWeapon() ? damageInfo.getLast() : 0;
     }
     
+    public boolean hasTimer() {
+        return timer != null;
+    }
+    
+    public String getTimerType() {
+        return hasTimer() ? timer.getFirst() : null;
+    }
+    
+    public int getTimerValue() {
+        return hasTimer() ? timer.getLast() : 0;
+    }
+    
+    public int getTimerDelay() {
+        return timerDelay;
+    }
+    
+    public boolean shouldProcessTimerOnBreak() {
+        return processTimerOnBreak;
+    }
+    
     public boolean isCraftable() {
         return !craftingIngredients.isEmpty();
     }
@@ -456,4 +538,16 @@ public class Item {
     public Map<ItemUseType, Object> getUses() {
         return useConfigs;
     }
+    
+    public Map<Item, Item> getConversions() {
+        return conversions.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey().get(), entry -> entry.getValue().get()));
+    }
+    
+    public boolean hasEntitySpawns() {
+        return !entitySpawns.isEmpty();
+    }
+    
+    public WeightedMap<String> getEntitySpawns() {
+        return entitySpawns;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java b/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java
index 4b82d2c..db35ed7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java
+++ b/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java
@@ -2,9 +2,11 @@ package brainwine.gameserver.item;
 
 import static brainwine.shared.LogMarkers.SERVER_MARKER;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import org.apache.logging.log4j.LogManager;
@@ -15,6 +17,7 @@ public class ItemRegistry {
     private static final Logger logger = LogManager.getLogger();
     private static final Map<String, Item> items = new HashMap<>();
     private static final Map<Integer, Item> itemsByCode = new HashMap<>();
+    private static final Map<String, List<Item>> itemsByCategory = new HashMap<>();
     
     // TODO maybe just move the registry stuff here
     public static void clear() {
@@ -36,6 +39,15 @@ public class ItemRegistry {
             return false;
         }
         
+        String category = item.getCategory();
+        List<Item> categorizedItems = itemsByCategory.get(category);
+        
+        if(categorizedItems == null) {
+            categorizedItems = new ArrayList<>();
+            itemsByCategory.put(category, categorizedItems);
+        }
+        
+        categorizedItems.add(item);
         items.put(id, item);
         itemsByCode.put(code, item);
         return true;
@@ -52,4 +64,8 @@ public class ItemRegistry {
     public static Collection<Item> getItems() {
         return Collections.unmodifiableCollection(items.values());
     }
+    
+    public static List<Item> getItemsByCategory(String category) {
+        return Collections.unmodifiableList(itemsByCategory.getOrDefault(category, Collections.emptyList()));
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java b/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java
index f2995fe..15282a7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java
+++ b/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java
@@ -3,27 +3,64 @@ package brainwine.gameserver.item;
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
 
+import brainwine.gameserver.item.interactions.BurstInteraction;
+import brainwine.gameserver.item.interactions.ChangeInteraction;
+import brainwine.gameserver.item.interactions.ContainerInteraction;
+import brainwine.gameserver.item.interactions.DialogInteraction;
+import brainwine.gameserver.item.interactions.ItemInteraction;
+import brainwine.gameserver.item.interactions.NoteInteraction;
+import brainwine.gameserver.item.interactions.SpawnInteraction;
+import brainwine.gameserver.item.interactions.SpawnTeleportInteraction;
+import brainwine.gameserver.item.interactions.SwitchInteraction;
+import brainwine.gameserver.item.interactions.TargetTeleportInteraction;
+import brainwine.gameserver.item.interactions.TeleportInteraction;
+import brainwine.gameserver.item.interactions.TransmitInteraction;
+
+/**
+ * Much like with {@link Action}, block interactions depend on their use type.
+ */
 public enum ItemUseType {
     
     AFTERBURNER,
-    CONTAINER,
-    CREATE_DIALOG,
-    DIALOG,
+    BURST(new BurstInteraction()),
+    CONTAINER(new ContainerInteraction()),
+    CREATE_DIALOG(new DialogInteraction(true)),
+    DESTROY,
+    DIALOG(new DialogInteraction(false)),
     GUARD,
-    CHANGE,
+    CHANGE(new ChangeInteraction()),
     FIELDABLE,
     FLY,
     MULTI,
+    NOTE(new NoteInteraction()),
+    PET,
+    PLENTY,
     PROTECTED,
     PUBLIC,
-    SWITCH,
+    SPAWN(new SpawnInteraction()),
+    SPAWN_TELEPORT(new SpawnTeleportInteraction()),
+    SWITCH(new SwitchInteraction()),
     SWITCHED,
-    TELEPORT,
+    TARGET_TELEPORT(new TargetTeleportInteraction()),
+    TELEPORT(new TeleportInteraction()),
+    TRIGGER,
+    TRANSMIT(new TransmitInteraction()),
+    TRANSMITTED,
     ZONE_TELEPORT,
     
     @JsonEnumDefaultValue
     UNKNOWN;
-        
+    
+    private final ItemInteraction interaction;
+    
+    private ItemUseType(ItemInteraction interaction) {
+        this.interaction = interaction;
+    }
+    
+    private ItemUseType() {
+        this(null);
+    }
+    
     @JsonCreator
     public static ItemUseType fromId(String id) {
         String formatted = id.toUpperCase().replace(" ", "_");
@@ -36,4 +73,8 @@ public enum ItemUseType {
         
         return UNKNOWN;
     }
+    
+    public ItemInteraction getInteraction() {
+        return interaction;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/Consumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/Consumable.java
new file mode 100644
index 0000000..32b3a77
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/Consumable.java
@@ -0,0 +1,9 @@
+package brainwine.gameserver.item.consumables;
+
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+
+public interface Consumable {
+    
+    public void consume(Item item, Player player, Object details);
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/ConvertConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/ConvertConsumable.java
new file mode 100644
index 0000000..8bf729c
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/ConvertConsumable.java
@@ -0,0 +1,88 @@
+package brainwine.gameserver.item.consumables;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import brainwine.gameserver.dialog.Dialog;
+import brainwine.gameserver.dialog.DialogSection;
+import brainwine.gameserver.dialog.input.DialogSelectInput;
+import brainwine.gameserver.entity.player.Inventory;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemRegistry;
+import brainwine.gameserver.server.messages.InventoryMessage;
+
+/**
+ * Consumable handler for upgrade kits
+ */
+public class ConvertConsumable implements Consumable {
+
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        Map<Item, Item> conversions = item.getConversions();
+        Inventory inventory = player.getInventory();
+        
+        // Find items in the player's inventory that can be upgraded
+        Set<Item> convertables = conversions.keySet().stream().filter(i -> inventory.hasItem(i)).collect(Collectors.toSet());
+        
+        // Don't do anything if the player has no items that can be converted
+        if(convertables.isEmpty()) {
+            player.notify("You do not have any upgradeable items.");
+            player.sendMessage(new InventoryMessage(inventory.getClientConfig(item)));
+            return;
+        }
+        
+        // Map item titles to their id
+        Map<String, String> keyMap = convertables.stream().collect(Collectors.toMap(Item::getTitle, Item::getId, (a, b) -> a));
+        
+        // Create upgrade dialog
+        Dialog dialog = new Dialog().addSection(new DialogSection()
+                .setTitle("Which item would you like to upgrade?")
+                .setInput(new DialogSelectInput()
+                        .setOptions(convertables.stream().map(Item::getTitle).collect(Collectors.toList()))
+                        .setMaxColumns(3)
+                        .setKey("item")));
+        
+        player.showDialog(dialog, data -> {
+            // Handle cancellation
+            if(data.length == 1 && data[0].equals("cancel")) {
+                player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+                return;
+            }
+            
+            // Fail if there is no data
+            if(data.length == 0) {
+                fail(item, player);
+                return;
+            }
+            
+            String key = keyMap.get(data[0]);
+            
+            // Fail if the chosen item title doesn't map to an id
+            if(key == null) {
+                fail(item, player);
+                return;
+            }
+            
+            Item itemToUpgrade = ItemRegistry.getItem(key);
+            Item targetItem = conversions.get(itemToUpgrade);
+            
+            // Fail if the player doesn't have the item they want to upgrade or there is no upgrade for it
+            if(!inventory.hasItem(itemToUpgrade) || targetItem == null) {
+                fail(item, player);
+                return;
+            }
+            
+            inventory.removeItem(item, true); // Remove the consumable
+            inventory.removeItem(itemToUpgrade, true); // Remove the item that was upgraded
+            inventory.addItem(targetItem, true); // Add the item that the item upgraded to :)
+            player.notify(String.format("%s upgraded to %s!", itemToUpgrade.getTitle(), targetItem.getTitle()));
+        });
+    }
+    
+    private void fail(Item item, Player player) {
+        player.notify("Oops! There was a problem with the upgrade.");
+        player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/HealConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/HealConsumable.java
new file mode 100644
index 0000000..5473f5b
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/HealConsumable.java
@@ -0,0 +1,16 @@
+package brainwine.gameserver.item.consumables;
+
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+
+/**
+ * Consumable handler for healing items
+ */
+public class HealConsumable implements Consumable {
+
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        player.heal(item.getPower());
+        player.getInventory().removeItem(item);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/NameChangeConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/NameChangeConsumable.java
new file mode 100644
index 0000000..90b3a1f
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/NameChangeConsumable.java
@@ -0,0 +1,67 @@
+package brainwine.gameserver.item.consumables;
+
+import java.util.regex.Pattern;
+
+import brainwine.gameserver.GameServer;
+import brainwine.gameserver.dialog.Dialog;
+import brainwine.gameserver.dialog.DialogHelper;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.entity.player.PlayerManager;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.server.messages.EventMessage;
+import brainwine.gameserver.server.messages.InventoryMessage;
+
+/**
+ * Consumable handler for name changers
+ */
+public class NameChangeConsumable implements Consumable {
+    
+    private static final Pattern namePattern = Pattern.compile("^[a-zA-Z0-9_.-]{4,20}$");
+    
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        PlayerManager playerManager = GameServer.getInstance().getPlayerManager();
+        Dialog dialog = DialogHelper.inputDialog("Change your name",
+                "Your in-game name can include letters, numbers, dashes and periods, and must be between 4 and 20 characters in length.");
+        
+        player.showDialog(dialog, data -> {
+            // Handle cancellation
+            if(data.length == 1 && data[0].equals("cancel")) {
+                player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+                return;
+            }
+            
+            String name = data.length == 1 ? "" + data[0] : null;
+            
+            // Check if the data is present
+            if(name == null) {
+                fail(item, player, "Oops! There was a problem with your request.");
+                return;
+            }
+            
+            // Check if the name is valid
+            if(!namePattern.matcher(name).matches()) {
+                fail(item, player, "Please enter a valid name.");
+                return;
+            }
+            
+            // Check if name is already taken
+            if(playerManager.getPlayer(name) != null) {
+                fail(item, player, "That name is taken already.");
+                return;
+            }
+            
+            player.getInventory().removeItem(item); // Remove the consumable
+            playerManager.changePlayerName(player, name); // Process the name change
+            
+            // TODO this creates a race condition
+            player.sendMessage(new EventMessage("playerNameDidChange", name)); // Client side processing stuff
+            player.kick("Your name has been changed."); // Force the player to reconnect
+        });
+    }
+    
+    private void fail(Item item, Player player, String message) {
+        player.showDialog(DialogHelper.messageDialog(message));
+        player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/RefillConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/RefillConsumable.java
new file mode 100644
index 0000000..a965a13
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/RefillConsumable.java
@@ -0,0 +1,16 @@
+package brainwine.gameserver.item.consumables;
+
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+
+/**
+ * Consumable handler for steam canisters
+ */
+public class RefillConsumable implements Consumable {
+
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        // All we do is remove the item because steam functionality is pretty much entirely client-side
+        player.getInventory().removeItem(item);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillConsumable.java
new file mode 100644
index 0000000..d62a661
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillConsumable.java
@@ -0,0 +1,93 @@
+package brainwine.gameserver.item.consumables;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.text.WordUtils;
+
+import brainwine.gameserver.dialog.Dialog;
+import brainwine.gameserver.dialog.DialogHelper;
+import brainwine.gameserver.dialog.DialogSection;
+import brainwine.gameserver.dialog.input.DialogSelectInput;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.entity.player.Skill;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.server.messages.InventoryMessage;
+
+/**
+ * Consumable handler for skill upgrade items
+ */
+public class SkillConsumable implements Consumable {
+    
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        List<Skill> bumpedSkills = player.getBumpedSkills().getOrDefault(item, Collections.emptyList());
+        
+        // Check if all skills have been bumped already
+        if(bumpedSkills.size() >= Skill.values().length) {
+            player.notify(String.format("You have already increased all of your skills with %ss.", item.getTitle().toLowerCase()));
+            player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+            return;
+        }
+        
+        // Assemble a list of skills that can be upgraded with this consumable
+        List<Skill> upgradeableSkills = Arrays.asList(Skill.values()).stream()
+                .filter(skill -> !bumpedSkills.contains(skill) && player.getSkillLevel(skill) < 10)
+                .collect(Collectors.toList());
+        
+        // Check if there are any skills to upgrade
+        if(upgradeableSkills.isEmpty()) {
+            player.notify("You have maximized all skills available for mastery.");
+            player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+            return;
+        }
+        
+        List<String> upgradeableSkillNames = upgradeableSkills.stream()
+                .map(Skill::getId)
+                .map(WordUtils::capitalize)
+                .collect(Collectors.toList());
+        
+        // Create dialog
+        Dialog dialog = new Dialog().addSection(new DialogSection()
+                .setTitle("Which skill would you like to increase?")
+                .setInput(new DialogSelectInput()
+                        .setOptions(upgradeableSkillNames)
+                        .setMaxColumns(3)
+                        .setKey("skill")));
+        
+        player.showDialog(dialog, data -> {
+            // Handle cancellation
+            if(data.length == 1 && data[0].equals("cancel")) {
+                player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+                return;
+            }
+            
+            // Verify data
+            if(data.length != 1) {
+                fail(item, player);
+                return;
+            }
+            
+            Skill skill = Skill.fromId("" + data[0]);
+            
+            // Make sure that the skill is still eligible for upgrading
+            if(skill == null || player.hasSkillBeenBumped(item, skill) || player.getSkillLevel(skill) >= 10) {
+                fail(item, player);
+                return;
+            }
+            
+            player.getInventory().removeItem(item, true); // Remove consumable
+            player.trackSkillBump(item, skill); // Track skill bump
+            player.setSkillLevel(skill, player.getSkillLevel(skill) + 1); // Increase skill level
+            player.showDialog(DialogHelper.messageDialog(String.format("%s increased!", WordUtils.capitalize(skill.toString().toLowerCase())),
+                    String.format("You now have additional mastery of %s.", skill.toString().toLowerCase())));
+        });
+    }
+    
+    private void fail(Item item, Player player) {
+        player.notify("Oops! There was a problem with upgrading your skill.");
+        player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java
new file mode 100644
index 0000000..3820566
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java
@@ -0,0 +1,62 @@
+package brainwine.gameserver.item.consumables;
+
+import java.util.Map.Entry;
+
+import brainwine.gameserver.dialog.Dialog;
+import brainwine.gameserver.dialog.DialogHelper;
+import brainwine.gameserver.dialog.DialogSection;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.entity.player.Skill;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.server.messages.InventoryMessage;
+
+/**
+ * Consumable handler for skill resets
+ */
+public class SkillResetConsumable implements Consumable {
+    
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        // Create dialog
+        Dialog dialog = new Dialog()
+                .setActions("yesno")
+                .addSection(new DialogSection()
+                        .setTitle("Confirm skill reset")
+                        .setText("Are you sure that you want to reset all of your skills back to level 1?"));
+                
+        player.showDialog(dialog, data -> {
+            // Handle cancellation
+            if(data.length == 1 && data[0].equals("cancel")) {
+                player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+                return;
+            }
+            
+            // Check if there are any skills to reset
+            if(!player.getSkills().values().stream().anyMatch(level -> level > 1)) {
+                player.showDialog(DialogHelper.messageDialog("You don't have any skills to reset."));
+                player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+                return;
+            }
+            
+            int pointsToRefund = 0;
+            
+            // Reset skill levels and calculate point refund total
+            for(Entry<Skill, Integer> entry : player.getSkills().entrySet()) {
+                Skill skill = entry.getKey();
+                int level = entry.getValue();
+                
+                // Skip if skill hasn't been upgraded at all
+                if(level <= 1) {
+                    continue;
+                }
+                
+                pointsToRefund += level - 1;
+                player.setSkillLevel(skill, 1); // Reset skill level
+            }
+            
+            player.getInventory().removeItem(item, true); // Remove the consumable
+            player.setSkillPoints(player.getSkillPoints() + pointsToRefund); // Refund skill points
+            player.showDialog(DialogHelper.getDialog("skill_reset"));
+        });
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java
new file mode 100644
index 0000000..562b49e
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java
@@ -0,0 +1,26 @@
+package brainwine.gameserver.item.consumables;
+
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+
+/**
+ * Consumable handler for stealth cloaks
+ */
+public class StealthConsumable implements Consumable {
+
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        player.getInventory().removeItem(item);
+        player.setStealth(true);
+        float seconds = item.getPower();
+        
+        // Apply skill power bonus
+        if(item.hasPowerBonus()) {
+            seconds += player.getTotalSkillLevel(item.getPowerBonus().getFirst()) * item.getPowerBonus().getLast();
+        }
+        
+        // Create timer
+        long delay = (long)(seconds * 1000);
+        player.addTimer("end stealth", delay, () -> player.setStealth(false));
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java
new file mode 100644
index 0000000..fa6138e
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java
@@ -0,0 +1,53 @@
+package brainwine.gameserver.item.consumables;
+
+import java.util.List;
+
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemUseType;
+import brainwine.gameserver.server.messages.InventoryMessage;
+import brainwine.gameserver.zone.MetaBlock;
+
+/**
+ * Consumable handler for portable teleporters.
+ * 
+ * These do not seem to function correctly on v3 clients.
+ */
+public class TeleportConsumable implements Consumable {
+    
+    @Override
+    public void consume(Item item, Player player, Object details) {
+        // Verify details
+        if(details == null || !(details instanceof List)) {
+            fail(item, player);
+            return;
+        }
+        
+        @SuppressWarnings("unchecked")
+        List<Object> coordinates = (List<Object>)details;
+        
+        // Verify coordinates
+        if(coordinates.size() != 2 || !(coordinates.get(0) instanceof Integer) || !(coordinates.get(1) instanceof Integer)) {
+            fail(item, player);
+            return;
+        }
+        
+        int x = (int)coordinates.get(0);
+        int y = (int)coordinates.get(1);
+        MetaBlock block = player.getZone().getMetaBlock(x, y);
+        
+        // Check if there is a teleporter at the target location
+        if(block == null || !block.getItem().hasUse(ItemUseType.TELEPORT, ItemUseType.ZONE_TELEPORT)) {
+            fail(item, player);
+            return;
+        }
+        
+        player.getInventory().removeItem(item);
+        player.teleport(x, y);
+    }
+    
+    private void fail(Item item, Player player) {
+        player.notify("Oops! There was a problem teleporting you to your target destination.");
+        player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item)));
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java
new file mode 100644
index 0000000..a9b8573
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java
@@ -0,0 +1,63 @@
+package brainwine.gameserver.item.interactions;
+
+import java.util.Map;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.entity.player.Skill;
+import brainwine.gameserver.item.DamageType;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.util.MapHelper;
+import brainwine.gameserver.zone.Block;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for items that explode if you get too close
+ */
+@SuppressWarnings("unchecked")
+public class BurstInteraction implements ItemInteraction {
+
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        // Do nothing if data is invalid
+        if(!(config instanceof Map)) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        Map<String, Object> configMap = (Map<String, Object>)config;
+        boolean dodge = MapHelper.getBoolean(configMap, "dodge");
+
+        // Do nothing if the player is lucky enough :)
+        if(dodge && Math.random() * Player.MAX_SKILL_LEVEL <= player.getTotalSkillLevel(Skill.AGILITY) / 2.0F) {
+            return;
+        }
+
+        boolean natural = MapHelper.getBoolean(configMap, "natural");
+        boolean enemy = MapHelper.getBoolean(configMap, "enemy");
+        Block block = zone.getBlock(x, y);
+        
+        // Check if the block has to be be natural or triggered by an enemy
+        if((natural && !block.isNatural()) || (enemy && (player.isStealthy() || block.getOwnerHash() == player.getBlockHash()))) {
+            return;
+        }
+
+        DamageType damageType = DamageType.fromName(MapHelper.getString(configMap, "damage_type"));
+        String effect = MapHelper.getString(configMap, "effect", "bomb");
+        float range = MapHelper.getFloat(configMap, "range");
+        float damage = MapHelper.getFloat(configMap, "damage");
+        boolean destructive = MapHelper.getBoolean(configMap, "destructive");
+        
+        // Create explosion and destroy block
+        zone.explode(x, y, range, null, destructive, damage, damageType, effect);
+        zone.updateBlock(x, y, layer, 0);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ChangeInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ChangeInteraction.java
new file mode 100644
index 0000000..5b07ae8
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ChangeInteraction.java
@@ -0,0 +1,26 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for blocks that can change between two states
+ */
+public class ChangeInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        zone.updateBlock(x, y, layer, item, mod == 0 ? 1 : 0, player);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java
new file mode 100644
index 0000000..14971f1
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java
@@ -0,0 +1,87 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.GameServer;
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemUseType;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.loot.Loot;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for lootable containers
+ */
+public class ContainerInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        // Check if the right data is present
+        if(metaBlock == null || data != null) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        String dungeonId = metaBlock.getStringProperty("@");
+        
+        // Check if container is protected by a dungeon
+        if(item.hasUse(ItemUseType.FIELDABLE) && dungeonId != null && zone.isDungeonIntact(dungeonId)) {
+            player.notify("This container is secured by protectors in the area.");
+            return;
+        }
+        
+        boolean plenty = item.hasUse(ItemUseType.PLENTY);
+        String lootCode = metaBlock.getStringProperty("y");
+        
+        // Check loot code
+        if(plenty) {
+            if(lootCode == null) {
+                player.notify("This chest cannot be plundered.");
+                return;
+            }
+            
+            if(player.hasLootCode(lootCode)) {
+                player.notify("You've already plundered this chest.");
+                return;
+            }
+        }
+        
+        String specialItem = metaBlock.getStringProperty("$");
+        
+        // Award loot
+        if(specialItem != null) {
+            if(specialItem.equals("?")) {
+                Loot loot = metaBlock.hasProperty("l") ? new Loot(Item.get(metaBlock.getStringProperty("l")), metaBlock.getIntProperty("q"))
+                        : GameServer.getInstance().getLootManager().getRandomLoot(player, item.getLootCategories());
+                int experience = metaBlock.getIntProperty("xp");
+                
+                if(loot != null) {
+                    if(plenty) {
+                        player.addLootCode(lootCode);
+                    } else {
+                        metaBlock.removeProperty("$");
+                        metaBlock.removeProperty("xp"); 
+                    }
+                    
+                    player.awardLoot(loot, item.getLootGraphic());
+                    player.addExperience(experience);
+                    player.getStatistics().trackContainerLooted(item);
+                } else {
+                    player.notify("No eligible loot could be found for this container.");
+                }
+            }
+        }
+        
+        // Update container mod
+        if(!plenty && !metaBlock.hasProperty("$")) {
+            zone.updateBlock(x, y, Layer.FRONT, item, 0, metaBlock.getOwner(), metaBlock.getMetadata());
+        }
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java
new file mode 100644
index 0000000..cd999e3
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java
@@ -0,0 +1,98 @@
+package brainwine.gameserver.item.interactions;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.util.MapHelper;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for blocks that may be configured through a dialog
+ */
+@SuppressWarnings("unchecked")
+public class DialogInteraction implements ItemInteraction {
+    
+    private boolean creationOnly;
+    
+    public DialogInteraction(boolean creationOnly) {
+        this.creationOnly = creationOnly;
+    }
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        // Do nothing if the required data isn't present
+        if(data == null || !(config instanceof Map)) {
+            return;
+        }
+        
+        // Do nothing if this block has already been configured and cannot be re-configured with this interaction
+        if(creationOnly && metaBlock != null && metaBlock.getBooleanProperty("cd")) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        Map<String, Object> configMap = (Map<String, Object>)config;
+        String target = MapHelper.getString(configMap, "target", "none");
+        
+        // Do nothing for now if the target isn't the block's metadata
+        if(!target.equals("meta")) {
+            player.notify("Sorry, this action isn't implemented yet.");
+            return;
+        }
+        
+        // Update block metadata
+        Map<String, Object> metadata = new HashMap<>();
+        List<Map<String, Object>> sections = MapHelper.getList(configMap, "sections");
+        
+        if(metaBlock != null) {
+            metadata.putAll(metaBlock.getMetadata());
+        }
+                
+        if(sections != null && data.length == sections.size()) {
+            for(int i = 0; i < sections.size(); i++) {
+                Map<String, Object> section = sections.get(i);
+                String key = MapHelper.getString(section, "input.key");
+                
+                if(key != null) {
+                    String text = String.valueOf(data[i]);
+                    
+                    // Get rid of text if player is currently muted
+                    if(player.isMuted() && MapHelper.getBoolean(section, "input.sanitize")) {
+                        text = text.replaceAll(".", "*");
+                    }
+                    
+                    metadata.put(key, text);
+                } else if(MapHelper.getBoolean(section, "input.mod")) {
+                    List<Object> options = MapHelper.getList(section, "input.options");
+                    
+                    if(options != null) {
+                        mod = options.indexOf(data[i]);
+                        mod = mod == -1 ? 0 : mod;
+                        mod *= MapHelper.getInt(section, "input.mod_multiple", 1);
+                        zone.updateBlock(x, y, layer, item, mod, player);
+                    }
+                }
+            }
+        }
+        
+        // Set configured flag
+        if(creationOnly) {
+            metadata.put("cd", true);
+        }
+        
+        // Update meta block
+        zone.setMetaBlock(x, y, item, player, metadata);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ItemInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ItemInteraction.java
new file mode 100644
index 0000000..ea95a94
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ItemInteraction.java
@@ -0,0 +1,13 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+public interface ItemInteraction {
+    
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data);
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/NoteInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/NoteInteraction.java
new file mode 100644
index 0000000..a58d587
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/NoteInteraction.java
@@ -0,0 +1,98 @@
+package brainwine.gameserver.item.interactions;
+
+import java.util.Collections;
+import java.util.List;
+
+import brainwine.gameserver.dialog.Dialog;
+import brainwine.gameserver.dialog.DialogHelper;
+import brainwine.gameserver.dialog.DialogSection;
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.NotificationType;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.util.MapHelper;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+public class NoteInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        // Do nothing if the right data isn't present
+        if(metaBlock == null || data != null) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        
+        // Check if note contains a location
+        if(metaBlock.hasProperty("l")) {
+            List<Integer> location = MapHelper.getList(metaBlock.getMetadata(), "l", Collections.emptyList());
+            String text = metaBlock.getStringProperty("t");
+            
+            // Do nothing if location data is invalid
+            if(location.size() != 2) {
+                return;
+            }
+            
+            int locationX = location.get(0);
+            int locationY = location.get(1);
+            
+            // Create dialog based on player version since v3 doesn't support map dialogs
+            if(player.isV3()) {
+                Dialog dialog = new Dialog()
+                    .addSection(new DialogSection()
+                            .setTitle("The note reads:")
+                            .setText(text))
+                    .addSection(new DialogSection()
+                            .setText(zone.getReadableCoordinates(locationX, locationY)));
+                player.showDialog(dialog);
+            } else {
+                // v2 dialog
+                Dialog dialog = new Dialog()
+                    .addSection(new DialogSection()
+                            .setTitle(text))
+                    .addSection(new DialogSection()
+                            .setLocation(locationX, locationY));
+                player.notify(dialog, NotificationType.NOTE);
+            }
+            
+            return;
+        }
+        
+        // Do nothing if player owns this note
+        if(metaBlock.isOwnedBy(player)) {
+            return;
+        }
+        
+        // Build string from note segments
+        String[] keys = { "t1", "t2", "t3", "t4", "t5", "t6" };
+        StringBuilder builder = new StringBuilder();
+        
+        for(int i = 0; i < keys.length; i++) {
+            String text = metaBlock.getStringProperty(keys[i]);
+            
+            // Skip if text is null or empty
+            if(text == null || text.isEmpty()) {
+                continue;
+            }
+            
+            // Append space if necessary
+            if(i > 0) {
+                builder.append(" ");
+            }
+            
+            builder.append(text);
+        }
+        
+        // Show note text in dialog
+        player.showDialog(DialogHelper.messageDialog("The note reads:", builder.toString()));
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/SpawnInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SpawnInteraction.java
new file mode 100644
index 0000000..52e7ae5
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SpawnInteraction.java
@@ -0,0 +1,27 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for items that can spawn entities
+ */
+public class SpawnInteraction implements ItemInteraction {
+
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if item can't spawn entities
+        if(!item.hasEntitySpawns() || mod != 0) {
+            return;
+        }
+        
+        // Try to spawn the entity and update block mod
+        if(zone.spawnEntity(item.getEntitySpawns().next(), x, y) != null) {
+            zone.updateBlock(x, y, layer, item, 1);
+        }
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/SpawnTeleportInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SpawnTeleportInteraction.java
new file mode 100644
index 0000000..f8f3c63
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SpawnTeleportInteraction.java
@@ -0,0 +1,39 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.GameServer;
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.zone.Biome;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for that one white teleporter in the tutorial world
+ */
+public class SpawnTeleportInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+                
+        // Find a random suitable zone
+        Zone targetZone = GameServer.getInstance().getZoneManager().getRandomZone(z -> z.getBiome() == Biome.PLAIN);
+        
+        // Notify the player if no zone could be found
+        if(targetZone == null) {
+            player.notify("Couldn't find a suitable zone to teleport to. Try again later.");
+            return;
+        }
+        
+        // Teleport the player to the target zone
+        player.changeZone(targetZone);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java
new file mode 100644
index 0000000..15c8229
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java
@@ -0,0 +1,205 @@
+package brainwine.gameserver.item.interactions;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.text.WordUtils;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.EntityConfig;
+import brainwine.gameserver.entity.EntityRegistry;
+import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.DamageType;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemUseType;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.util.MapHelper;
+import brainwine.gameserver.util.Vector2i;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for switches
+ */
+public class SwitchInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if the required data isn't present
+        if(data != null || metaBlock == null) {
+            return;
+        }
+        
+        int timer = metaBlock.getIntProperty("t");
+        
+        // Do nothing if this switch has a timer and is already flipped
+        if(timer > 0 && mod % 2 == 1) {
+            return;
+        }
+        
+        // Show configured message to nearby players
+        String message = metaBlock.getStringProperty("m");
+        
+        if(message != null && !message.isEmpty()) {
+            float effectX = x + item.getBlockWidth() / 2.0F;
+            float effectY = y - item.getBlockHeight() + 1;
+            zone.spawnEffect(effectX, effectY, "emote", message);
+        }
+        
+        // Prepare list of targets
+        List<List<Integer>> positions = MapHelper.getList(metaBlock.getMetadata(), ">", Collections.emptyList());
+        List<Vector2i> targets = new ArrayList<>();
+        targets.add(new Vector2i(x, y));
+        positions.stream().map(position -> new Vector2i(position.get(0), position.get(1))).forEach(targets::add);
+        int switchedMod = mod % 2 == 0 ? mod + 1 : mod - 1;
+        
+        // Switch all target blocks
+        for(Vector2i target : targets) {
+            switchBlock(zone, entity, target.getX(), target.getY(), switchedMod, metaBlock);
+        }
+        
+        // Create block timer if this is a timed switch
+        if(timer > 0) {
+            int unswitchedMod = switchedMod % 2 == 0 ? switchedMod + 1 : switchedMod - 1;
+            
+            zone.addBlockTimer(x, y, timer * 1000, () -> {
+                for(Vector2i target : targets) {
+                    switchBlock(zone, entity, target.getX(), target.getY(), unswitchedMod, metaBlock);
+                }
+            });
+        }
+    }
+    
+    private void switchBlock(Zone zone, Entity entity, int x, int y, int mod, MetaBlock switchMeta) {
+        // Do nothing if the target chunk isn't loaded
+        if(!zone.isChunkLoaded(x, y)) {
+            return;
+        }
+        
+        MetaBlock metaBlock = zone.getMetaBlock(x, y);
+        
+        // Do nothing if there is no metadata
+        if(metaBlock == null) {
+            return;
+        }
+        
+        Player owner = metaBlock == null ? null : metaBlock.getOwner();
+        Map<String, Object> metadata = metaBlock == null ? null : metaBlock.getMetadata();
+        Item item = metaBlock.getItem();
+        Object config = item.getUse(ItemUseType.SWITCHED);
+        
+        if(config instanceof String) {
+            String type = (String)config;
+            
+            // Not the prettiest way to do this but it will have to do.
+            switch(type.toLowerCase()) {
+            case "spawner": switchSpawner(zone, metaBlock); break;
+            case "exploder": switchExploder(zone, entity, metaBlock); break;
+            case "messagesign": switchSign(zone, entity, metaBlock, switchMeta); break;
+            default: break;
+            }
+        } else if(item.hasUse(ItemUseType.SWITCH, ItemUseType.SWITCHED, ItemUseType.TRIGGER)) {
+            zone.updateBlock(x, y, Layer.FRONT, item, mod, owner, metadata);
+        }
+    }
+    
+    private void switchSpawner(Zone zone, MetaBlock metaBlock) {
+        // Kill existing entity
+        if(metaBlock.hasProperty("eid")) {
+            Entity entity = zone.getEntity(metaBlock.getIntProperty("eid"));
+            
+            if(entity != null && !entity.isDead()) {
+                entity.spawnEffect("bomb-teleport", 4);
+                entity.setHealth(0);
+            }
+        }
+        
+        Object config = metaBlock.getItem().getUse(ItemUseType.SPAWN);
+        
+        // Do nothing if use config data is invalid
+        if(!(config instanceof Map)) {
+            return;
+        }
+        
+        // Try to spawn entity
+        String entityType = MapHelper.getString((Map<?, ?>)config, metaBlock.getStringProperty("e"));
+        Npc npc = zone.spawnEntity(entityType, metaBlock.getX(), metaBlock.getY(), true);
+        
+        // Do nothing if entity failed to spawn
+        if(npc == null) {
+            return;
+        }
+        
+        npc.setArtificial(true);
+        metaBlock.setProperty("eid", npc.getId());
+    }
+    
+    // TODO exploders were used to create lag machines back in the day, so maybe we should put a cooldown on this
+    private void switchExploder(Zone zone, Entity entity, MetaBlock metaBlock) {
+        String type = metaBlock.getStringProperty("e");
+        
+        // Do nothing if explosion type doesn't exist in block metadata
+        if(type == null) {
+            return;
+        }
+        
+        // Create explosion
+        DamageType damageType = type.equalsIgnoreCase("electric") ? DamageType.ENERGY : DamageType.fromName(type);
+        String effect = String.format("bomb-%s", type.toLowerCase());
+        zone.explode(metaBlock.getX(), metaBlock.getY(), 6, entity, false, 6, damageType, effect);
+    }
+    
+    private void switchSign(Zone zone, Entity entity, MetaBlock metaBlock, MetaBlock switchMeta) {
+        String message = switchMeta.hasProperty("m") ? switchMeta.getStringProperty("m").trim() : "";
+        boolean lock = metaBlock.hasProperty("lock") && metaBlock.getStringProperty("lock").equalsIgnoreCase("yes");
+        Item item = metaBlock.getItem();
+        
+        // Check and update lock status
+        if(lock) {
+            boolean locked = metaBlock.getBooleanProperty("locked");
+            
+            if(!message.isEmpty()) {
+                if(locked) {
+                    return;
+                }
+                
+                metaBlock.setProperty("locked", true);
+            } else if(locked) {
+                metaBlock.removeProperty("locked");
+            }
+        }
+        
+        // Update sign text
+        String name = entity.getName();
+        
+        if(name != null) {
+            message = message.replaceAll("%t%", name);
+        }
+        
+        String separator = "\n";
+        String[] keys = {"t1", "t2", "t3", "t4"};
+        String[] segments = WordUtils.wrap(message, 20, separator, true).split(separator, 4);
+       
+        for(int i = 0; i < keys.length; i++) {
+            String key = keys[i];
+            String text = i < segments.length ? segments[i] : "";
+            int separatorIndex = text.lastIndexOf(separator);
+            
+            if(separatorIndex != -1) {
+                text = text.substring(0, separatorIndex);
+            }
+            
+            metaBlock.setProperty(key, text);
+        }
+        
+        // Send data to players
+        float effectX = metaBlock.getX() + (float)item.getBlockWidth() / 2;
+        float effectY = metaBlock.getY() - (float)item.getBlockHeight() / 2 + 1;
+        zone.spawnEffect(effectX, effectY, "area steam", 10);
+        zone.sendBlockMetaUpdate(metaBlock);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java
new file mode 100644
index 0000000..01f8033
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java
@@ -0,0 +1,102 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.GameServer;
+import brainwine.gameserver.dialog.Dialog;
+import brainwine.gameserver.dialog.DialogSection;
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.zone.Biome;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+public class TargetTeleportInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        // Do nothing if data is invalid
+        if(data != null) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        Zone targetZone = zone;
+        String zoneName = metaBlock.getStringProperty("pz");
+        
+        // Validate target zone
+        if(zoneName != null) {
+            targetZone = GameServer.getInstance().getZoneManager().getZoneByName(zoneName);
+            
+            if(targetZone == null) {
+                player.notify(String.format("Cannot locate world '%s', please recalibrate.", zoneName));
+                return;
+            }
+        }
+        
+        // Parse target position
+        int targetX = -1;
+        int targetY = metaBlock.getIntProperty("py") + (targetZone.getBiome() == Biome.DEEP ? -1000 : 200);
+        int centerX = zone.getWidth() / 2;
+        
+        try {
+            String strX = metaBlock.getStringProperty("px");
+            
+            if(strX != null) {
+                if(strX.endsWith("w")) {
+                    targetX = centerX - Integer.parseInt(strX.replace("w", ""));
+                } else {
+                    targetX = centerX + Integer.parseInt(strX.replace("e", ""));
+                }
+            }
+        } catch(NumberFormatException e) {
+            // Discard silently
+        }
+        
+        // Do nothing if target is out of bounds
+        if(!targetZone.areCoordinatesInBounds(targetX, targetY)) {
+            player.notify("Cannot locate destination, please recalibrate.");
+            return;
+        }
+        
+        // Do nothing if target location is unexplored
+        if(!player.isGodMode() && !targetZone.isAreaExplored(targetX, targetY)) {
+            player.notify("That area hasn't been explored yet.");
+            return;
+        }
+        
+        // Do nothing if target location is protected
+        if(!player.isGodMode() && targetZone.isBlockProtected(targetX, targetY, player)) {
+            player.notify("That area is protected.");
+            return;
+        }
+        
+        // Teleport the player to the target location
+        if(targetZone == zone) {
+            player.teleport(targetX, targetY);
+        } else {
+            // Create confirmation dialog
+            Dialog dialog = new Dialog()
+                    .setActions("yesno")
+                    .addSection(new DialogSection()
+                            .setTitle("Attention")
+                            .setText(String.format("Teleport to world '%s'?", targetZone.getName())));
+            
+            // Show confirmation dialog for zone change
+            Zone _targetZone = targetZone;
+            int _targetX = targetX;
+            int _targetY = targetY;
+            player.showDialog(dialog, input -> {
+                if(input.length == 1 && input[0].equals("Yes")) {
+                    player.changeZone(_targetZone, _targetX, _targetY);
+                }
+            });
+        }
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TeleportInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TeleportInteraction.java
new file mode 100644
index 0000000..70989e2
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TeleportInteraction.java
@@ -0,0 +1,56 @@
+package brainwine.gameserver.item.interactions;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.NotificationType;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemUseType;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for teleporters
+ */
+public class TeleportInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        
+        // Try to repair teleporter
+        if(mod == 0) {
+            zone.updateBlock(x, y, layer, item, 1);
+            player.getStatistics().trackDiscovery(item);
+            player.notify("You repaired a teleporter!", NotificationType.ACCOMPLISHMENT);
+            player.notifyPeers(String.format("%s repaired a teleporter.", player.getName()), NotificationType.SYSTEM);
+            return;
+        }
+        
+        // Verify data
+        if(data == null || data.length != 2 || mod != 1) {
+            return;
+        }
+        
+        int targetX = data[0] instanceof Integer ? (int)data[0] : -1;
+        int targetY = data[1] instanceof Integer ? (int)data[1] : -1;
+        MetaBlock targetMeta = zone.getMetaBlock(targetX, targetY);
+        
+        // Do nothing if target has no metadata
+        if(targetMeta == null) {
+            return;
+        }
+                
+        // Teleport the player if the target location is valid
+        if((targetMeta.getItem().hasUse(ItemUseType.TELEPORT) && zone.getBlock(targetX, targetY).getFrontMod() == 1) 
+                || targetMeta.getItem().hasUse(ItemUseType.ZONE_TELEPORT)) {
+            player.teleport(targetX + 1, targetY);
+        }
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TransmitInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TransmitInteraction.java
new file mode 100644
index 0000000..20bbc94
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TransmitInteraction.java
@@ -0,0 +1,58 @@
+package brainwine.gameserver.item.interactions;
+
+import java.util.Collections;
+import java.util.List;
+
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemUseType;
+import brainwine.gameserver.item.Layer;
+import brainwine.gameserver.util.MapHelper;
+import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
+
+/**
+ * Interaction handler for target teleporters
+ */
+public class TransmitInteraction implements ItemInteraction {
+    
+    @Override
+    public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock,
+            Object config, Object[] data) {
+        // Do nothing if entity is not a player
+        if(!entity.isPlayer()) {
+            return;
+        }
+        
+        // Do nothing if the required data isn't present
+        if(data != null || metaBlock == null) {
+            return;
+        }
+        
+        Player player = (Player)entity;
+        List<List<Integer>> positions = MapHelper.getList(metaBlock.getMetadata(), ">", Collections.emptyList());
+        
+        // Do nothing if there is no linked position
+        if(positions.isEmpty()) {
+            return;
+        }
+        
+        List<Integer> position = positions.get(0);
+        int targetX = position.get(0);
+        int targetY = position.get(1);
+        
+        // Make sure that the target location is in bounds
+        if(!zone.areCoordinatesInBounds(targetX, targetY)) {
+            return;
+        }
+        
+        // Notify the player if the target beacon is missing
+        if(!zone.getBlock(targetX, targetY).getFrontItem().hasUse(ItemUseType.TRANSMITTED)) {
+            player.notify("There is no beacon at the target location.");
+            return;
+        }
+        
+        player.teleport(targetX, targetY);
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/loot/Loot.java b/gameserver/src/main/java/brainwine/gameserver/loot/Loot.java
index f1a3900..90f7bbf 100644
--- a/gameserver/src/main/java/brainwine/gameserver/loot/Loot.java
+++ b/gameserver/src/main/java/brainwine/gameserver/loot/Loot.java
@@ -28,6 +28,13 @@ public class Loot {
     @JsonCreator
     private Loot() {}
     
+    /**
+     * Arbitrary constructor for chests o' plenty
+     */
+    public Loot(Item item, int quantity) {
+        this.items.put(item, quantity);
+    }
+    
     public Map<Item, Integer> getItems() {
         return items;
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/messages/EffectMessage.java b/gameserver/src/main/java/brainwine/gameserver/server/messages/EffectMessage.java
index ec3fc2c..adae916 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/messages/EffectMessage.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/messages/EffectMessage.java
@@ -10,12 +10,12 @@ public class EffectMessage extends Message {
     public int x;
     public int y;
     public String name;
-    public int count;
+    public Object data;
     
-    public EffectMessage(float x, float y, String name, int count) {
+    public EffectMessage(float x, float y, String name, Object data) {
         this.x = (int)(x * Entity.POSITION_MODIFIER);
         this.y = (int)(y * Entity.POSITION_MODIFIER);
         this.name = name;
-        this.count = count;
+        this.data = data;
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityItemUseMessage.java b/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityItemUseMessage.java
index aec81da..3538198 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityItemUseMessage.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityItemUseMessage.java
@@ -1,21 +1,29 @@
 package brainwine.gameserver.server.messages;
 
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
 import brainwine.gameserver.annotations.MessageInfo;
+import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.server.Message;
+import brainwine.gameserver.server.models.EntityItemUseData;
 
-@MessageInfo(id = 10, collection = true)
+@MessageInfo(id = 10, prepacked = true)
 public class EntityItemUseMessage extends Message {
     
-    public int entityId;
-    public int type;
-    public Item item;
-    public int status;
+    public Collection<EntityItemUseData> data;
     
-    public EntityItemUseMessage(int entityId, int type, Item item, int status) {
-        this.entityId = entityId;
-        this.type = type;
-        this.item = item;
-        this.status = status;
+    public EntityItemUseMessage(Collection<? extends Player> players) {
+        this.data = players.stream().map(EntityItemUseData::new).collect(Collectors.toList());
+    }
+    
+    public EntityItemUseMessage(Player player) {
+        this.data = Arrays.asList(new EntityItemUseData(player));
+    }
+    
+    public EntityItemUseMessage(int id, int type, Item item, int status) {
+        this.data = Arrays.asList(new EntityItemUseData(id, type, item, status));
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityStatusMessage.java b/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityStatusMessage.java
index accc0d7..cf2c420 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityStatusMessage.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/messages/EntityStatusMessage.java
@@ -28,6 +28,10 @@ public class EntityStatusMessage extends Message {
         this(Arrays.asList(new EntityStatusData(entity, status)));
     }
     
+    public EntityStatusMessage(Entity entity, EntityStatus status, Map<String, Object> details) {
+        this(Arrays.asList(new EntityStatusData(entity, status, details)));
+    }
+    
     public EntityStatusMessage(int id, int type, String name, EntityStatus status, Map<String, Object> details) {
         this(Arrays.asList(new EntityStatusData(id, type, name, status, details)));
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneStatusMessage.java b/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneStatusMessage.java
index 635ba75..09c3b3b 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneStatusMessage.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneStatusMessage.java
@@ -1,16 +1,14 @@
 package brainwine.gameserver.server.messages;
 
-import java.util.Map;
-
 import brainwine.gameserver.annotations.MessageInfo;
 import brainwine.gameserver.server.Message;
 
 @MessageInfo(id = 17)
 public class ZoneStatusMessage extends Message {
     
-    public Map<String, Object> status;
+    public Object status;
     
-    public ZoneStatusMessage(Map<String, Object> status) {
+    public ZoneStatusMessage(Object status) {
         this.status = status;
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/models/EntityItemUseData.java b/gameserver/src/main/java/brainwine/gameserver/server/models/EntityItemUseData.java
new file mode 100644
index 0000000..9c663da
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/server/models/EntityItemUseData.java
@@ -0,0 +1,43 @@
+package brainwine.gameserver.server.models;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonFormat.Shape;
+
+import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.Item;
+
+@JsonFormat(shape = Shape.ARRAY)
+public class EntityItemUseData {
+    
+    private final int id;
+    private final int type;
+    private final Item item;
+    private final int status;
+    
+    public EntityItemUseData(Player player) {
+        this(player.getId(), 0, player.getHeldItem(), 0);
+    }
+    
+    public EntityItemUseData(int id, int type, Item item, int status) {
+        this.id = id;
+        this.type = type;
+        this.item = item;
+        this.status = status;
+    }
+    
+    public int getId() {
+        return id;
+    }
+    
+    public int getType() {
+        return type;
+    }
+    
+    public Item getItem() {
+        return item;
+    }
+    
+    public int getStatus() {
+        return status;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/models/EntityStatusData.java b/gameserver/src/main/java/brainwine/gameserver/server/models/EntityStatusData.java
index e753976..ccc9f34 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/models/EntityStatusData.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/models/EntityStatusData.java
@@ -18,7 +18,11 @@ public class EntityStatusData {
     private final Map<String, Object> details;
     
     public EntityStatusData(Entity entity, EntityStatus status) {
-        this(entity.getId(), entity.getType(), entity.getName(), status, entity.getStatusConfig());
+        this(entity, status, entity.getStatusConfig());
+    }
+    
+    public EntityStatusData(Entity entity, EntityStatus status, Map<String, Object> details) {
+        this(entity.getId(), entity.getType(), entity.getName(), status, details);
     }
     
     public EntityStatusData(int id, int type, String name, EntityStatus status, Map<String, Object> details) {
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java
index 777f5d6..e5888bc 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java
@@ -5,6 +5,10 @@ import java.util.List;
 import java.util.Map;
 
 import brainwine.gameserver.annotations.RequestInfo;
+import brainwine.gameserver.entity.Entity;
+import brainwine.gameserver.entity.EntityConfig;
+import brainwine.gameserver.entity.EntityRegistry;
+import brainwine.gameserver.entity.npc.Npc;
 import brainwine.gameserver.entity.player.NotificationType;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.entity.player.Skill;
@@ -91,6 +95,23 @@ public class BlockMineRequest extends PlayerRequest {
             return;
         }
         
+        // Apply decay if block is being mined with a hatchet
+        if(item.getMod() == ModType.DECAY && player.getHeldItem().getAction() == Action.SMASH) {
+            int nextMod = Math.min(4, block.getMod(layer) + 1);
+            zone.updateBlock(x, y, layer, item, nextMod);
+            
+            // Send inventory message for v3 players
+            if(player.isV3()) {
+                Item decayItem = item.getDecayInventoryItem();
+                
+                if(!decayItem.isAir()) {
+                    player.sendDelayedMessage(new InventoryMessage(player.getInventory().getClientConfig(decayItem)));
+                }
+            }
+            
+            return;
+        }
+        
         if(metaBlock != null) {
             Map<String, Object> metadata = metaBlock.getMetadata();
             
@@ -125,10 +146,33 @@ public class BlockMineRequest extends PlayerRequest {
             }
         }
         
-        zone.updateBlock(x, y, layer, 0, 0, player);
-        player.getStatistics().trackItemMined(item);
+        if(item.shouldProcessTimerOnBreak()) {
+            zone.processBlockTimer(x, y);
+        }
+        
+        // Pretty much only used for spawners
+        if(item.hasUse(ItemUseType.DESTROY)) {
+            Object config = item.getUse(ItemUseType.DESTROY);
+            
+            if(config instanceof String) {
+                String type = (String)config;
+                
+                switch(type.toLowerCase()) {
+                case "spawner": destroySpawner(zone, metaBlock); break;
+                default: break;
+                }
+            }
+        }
+        
+        // Check for entity spawns
+        if(item.hasEntitySpawns() && block.getMod(layer) == 0 && !item.hasTimer() && !item.hasUse(ItemUseType.SPAWN)) {
+            zone.spawnEntity(item.getEntitySpawns().next(), x, y);
+        }
+        
         Item inventoryItem = item.getMod() == ModType.DECAY && block.getMod(layer) > 0 ? item.getDecayInventoryItem() : item.getInventoryItem();
         int quantity = 1;
+        player.getStatistics().trackItemMined(item);
+        zone.updateBlock(x, y, layer, 0, 0, player);
         
         // Apply mining bonus if there is one
         if(item.hasMiningBonus()) {
@@ -151,6 +195,21 @@ public class BlockMineRequest extends PlayerRequest {
         }
     }
     
+    private void destroySpawner(Zone zone, MetaBlock metaBlock) {
+        // Do nothing if spawner doesn't have an entity
+        if(!metaBlock.hasProperty("eid")) {
+            return;
+        }
+        
+        Entity entity = zone.getEntity(metaBlock.getIntProperty("eid"));
+        
+        // Kill entity if it exists
+        if(entity != null && !entity.isDead()) {
+            entity.spawnEffect("bomb-teleport", 4);
+            entity.setHealth(0);
+        }
+    }
+    
     private void fail(Player player, String reason) {
         player.notify(reason);
         Block block = player.getZone().getBlock(x, y);
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java
index d74fbca..3abbcc3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java
@@ -1,9 +1,17 @@
 package brainwine.gameserver.server.requests;
 
+import java.util.UUID;
+
 import brainwine.gameserver.annotations.RequestInfo;
+import brainwine.gameserver.entity.EntityConfig;
+import brainwine.gameserver.entity.EntityRegistry;
+import brainwine.gameserver.entity.npc.Npc;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.entity.player.Skill;
+import brainwine.gameserver.item.DamageType;
 import brainwine.gameserver.item.Item;
+import brainwine.gameserver.item.ItemGroup;
+import brainwine.gameserver.item.ItemUseType;
 import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.item.ModType;
 import brainwine.gameserver.server.PlayerRequest;
@@ -12,6 +20,7 @@ import brainwine.gameserver.server.messages.InventoryMessage;
 import brainwine.gameserver.util.MathUtils;
 import brainwine.gameserver.util.Pair;
 import brainwine.gameserver.zone.Block;
+import brainwine.gameserver.zone.MetaBlock;
 import brainwine.gameserver.zone.Zone;
 
 @RequestInfo(id = 12)
@@ -56,7 +65,7 @@ public class BlockPlaceRequest extends PlayerRequest {
             return;
         }
         
-        if(!player.isGodMode() && zone.isBlockProtected(x, y, player)) {
+        if(!player.isGodMode() && !item.canPlaceInField() && zone.isBlockProtected(x, y, player)) {
             fail(player, "This block is protected.");
             return;
         }
@@ -100,18 +109,190 @@ public class BlockPlaceRequest extends PlayerRequest {
         player.getStatistics().trackItemPlaced();
         player.trackPlacement(x, y, item);
         
+        // Create block timer if applicable
+        if(item.hasTimer()) {
+            createBlockTimer(zone, player);
+        }
+        
         // Process custom place if applicable
         if(item.hasCustomPlace()) {
-            processCustomPlace(player);
+            processCustomPlace(zone, player);
+        }
+        
+        // Misc processing
+        if(item.getGroup() == ItemGroup.GRAVESTONE) {
+            processBurial(zone, player);
+        } else if(item.getGroup() == ItemGroup.CAGE) {
+            processTrapping(zone, player);
         }
     }
     
-    private void processCustomPlace(Player player) {
-        Zone zone = player.getZone();
+    private void processTrapping(Zone zone, Player player) {
+        // Check bounds
+        if(x <= 0 || x + 1 >= zone.getWidth() || y <= 0 || y + 1 >= zone.getHeight()) {
+            return;
+        }
         
+        // Do nothing if cage is not surrounded by whole blocks
+        if(!zone.isBlockWhole(x - 1, y - 1) || !zone.isBlockWhole(x, y - 1) || !zone.isBlockWhole(x + 1, y - 1) || !zone.isBlockWhole(x - 1, y) || !zone.isBlockWhole(x + 1, y)
+                || !zone.isBlockWhole(x - 1, y + 1) || !zone.isBlockWhole(x, y + 1) || !zone.isBlockWhole(x + 1, y + 1)) {
+            return;
+        }
+        
+        // Find random trappable entity at this location
+        // TODO we have to do an isDead() check here because dead NPCs aren't always cleared immediately
+        Npc entity = zone.getNpcs().stream()
+                .filter(npc -> !npc.isDead() && !npc.isArtificial() && npc.getBlockX() == x && npc.getBlockY() == y && npc.getConfig().isTrappable())
+                .findFirst().orElse(null);
+        
+        // Do nothing if no eligible entity was found
+        if(entity == null) {
+            return;
+        }
+        
+        EntityConfig config = entity.getConfig();
+        
+        // Try to turn entity into a pet cage
+        if(item.hasUse(ItemUseType.PET)) {
+            // Don't waste it if entity has no pet variant
+            if(!config.hasTrappablePetItem()) {
+                return;
+            }
+            
+            entity.setHealth(0.0F);
+            zone.updateBlock(x, y, layer, 0);
+            player.getInventory().addItem(config.getTrappablePetItem(), true);
+            player.getStatistics().trackTrapping(config);
+            return;
+        }
+        
+        // Otherwise, kill the entity and place some fur
+        // TODO v2 stores the quantity in the mod of "piled" items, but this functionality is not implemented here at all!
+        entity.attack(player, item, entity.getHealth(), DamageType.ACID, true);
+        zone.updateBlock(x, y, Layer.FRONT, "ground/fur");
+        player.getStatistics().trackTrapping(config);
+    }
+    
+    private void processBurial(Zone zone, Player player) {
+        // Check bounds
+        if(x <= 0 || x + 2 >= zone.getWidth() || y + 2 >= zone.getHeight()) {
+            return;
+        }
+        
+        // Do nothing if there is no skeleton underneath the gravestone
+        if(!zone.getBlock(x, y + 1).getFrontItem().hasId("rubble/skeleton")) {
+            return;
+        }
+        
+        // Do nothing if the skeleton is obstructed
+        if(zone.isBlockOccupied(x + 1, y + 1, Layer.FRONT)) {
+            return;
+        }
+        
+        // Do nothing if the skeleton isn't underground
+        if(!zone.isUnderground(x, y + 1) || !zone.isUnderground(x + 1, y + 1)) {
+            return;
+        }
+        
+        // Do nothing if the gravestone isn't above ground
+        if(zone.isUnderground(x, y) || zone.isUnderground(x + 1, y)) {
+            return;
+        }
+        
+        // Do nothing if the skeleton isn't surrounded by earth
+        if(!zone.isBlockEarthy(x - 1, y + 1) || !zone.isBlockEarthy(x + 2, y + 1) || !zone.isBlockEarthy(x, y + 2) || !zone.isBlockEarthy(x + 1, y + 2)) {
+            return;
+        }
+        
+        // Everything checks out -- fill the grave!
+        zone.updateBlock(x, y + 1, Layer.FRONT, "ground/earth");
+        zone.updateBlock(x + 1, y + 1, Layer.FRONT, "ground/earth");
+        zone.spawnEffect(x + 1.0F, y + 0.5F, "expiate", 20);
+        zone.spawnEffect(x + 1.0F, y + 0.5F, "sparkle up", 20);
+        
+        // ~33% chance to spawn a ghost
+        if(Math.random() < 0.334) {
+            zone.spawnEntity("ghost", x + 1, y);
+        }
+        
+        player.getStatistics().trackUndertaking();
+    }
+    
+    private void createBlockTimer(Zone zone, Player player) {
+        String type = item.getTimerType();
+        int value = item.getTimerValue();
+        Runnable task = null;
+        
+        switch(type) {
+        case "front mod":
+            task = () -> zone.updateBlock(x, y, layer, item, value);
+            break;
+        case "bomb":
+            task = () -> zone.explode(x, y, value, player, true, value, DamageType.FIRE, value >= 6 ? "bomb-large" : "bomb");
+            break;
+        case "bomb-fire":
+            task = () -> zone.explode(x, y, value, player, false, value, DamageType.FIRE, "bomb-fire");
+            break;
+        case "bomb-electric":
+            task = () -> zone.explode(x, y, value, player, false, value, DamageType.ENERGY, "bomb-electric");
+            break;
+        case "bomb-frost":
+            task = () -> zone.explode(x, y, value, player, false, value, DamageType.COLD, "bomb-frost");
+            break;
+        case "bomb-dig":
+            task = () -> {
+                zone.explode(x, y, value, player, "bomb-fire");
+                int distance = value * 10;
+                
+                // Dig until we reach the maximum distance or hit a solid block
+                for(int i = 1; i <= distance; i++) {
+                    if(!zone.digBlock(x, y + i)) {
+                        break;
+                    }
+                }
+            };
+            break;
+        case "bomb-spawner":
+            task = () -> {
+                zone.explode(x, y, value, player, false, value, DamageType.FIRE, "bomb-fire");
+                
+                // Spawn a bunch of entities
+                for(int i = 0; i < value; i++) {
+                    zone.spawnEntity(item.getEntitySpawns().next(), x, y);
+                }
+            };
+            break;
+        case "bomb-water":
+            task = () -> {
+                zone.explode(x, y, value, player, false, value, DamageType.COLD, "bomb-large");
+                zone.explodeLiquid(x, y, 4, "liquid/water");
+            };
+            break;
+        case "bomb-acid":
+            task = () -> {
+                zone.explode(x, y, value, player, false, value, DamageType.ACID, "bomb-large");
+                zone.explodeLiquid(x, y, 4, "liquid/acid");
+            };
+            break;
+        case "bomb-lava":
+            task = () -> {
+                zone.explode(x, y, value, player, false, value, DamageType.FIRE, "bomb-large");
+                zone.explodeLiquid(x, y, 4, "liquid/magma");
+            };
+            break;
+        default:
+            break;
+        }
+        
+        if(task != null) {
+            zone.addBlockTimer(x, y, item.getTimerDelay() * 1000, task);
+        }
+    }
+    
+    private void processCustomPlace(Zone zone, Player player) {        
         switch(item.getId()) {
-            // See if we can plug a maw or pipe
             case "building/plug":
+                // See if we can plug a maw or pipe
                 Item baseItem = zone.getBlock(x, y).getBaseItem();
                 String plugged = baseItem.hasId("base/maw") ? "base/maw-plugged"
                         : baseItem.hasId("base/pipe") ? "base/pipe-plugged" : null;
@@ -122,6 +303,16 @@ public class BlockPlaceRequest extends PlayerRequest {
                     player.getStatistics().trackMawPlugged();
                 }
                 
+                break;
+            case "containers/chest-plenty":
+            case "containers/sack-plenty":
+                // Create additional metadata for chests o' plenty
+                MetaBlock metaBlock = zone.getMetaBlock(x, y);
+                
+                if(metaBlock != null) {
+                    metaBlock.setProperty("y", UUID.randomUUID().toString()); // Generate random loot code
+                    metaBlock.setProperty("$", "?");
+                }
                 break;
             // No valid item; do nothing
             default: break;
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java
index 6f4a00c..c8ecdc3 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java
@@ -1,27 +1,19 @@
 package brainwine.gameserver.server.requests;
 
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 
-import brainwine.gameserver.GameServer;
 import brainwine.gameserver.annotations.OptionalField;
 import brainwine.gameserver.annotations.RequestInfo;
-import brainwine.gameserver.entity.player.NotificationType;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.ItemUseType;
 import brainwine.gameserver.item.Layer;
-import brainwine.gameserver.loot.Loot;
+import brainwine.gameserver.item.interactions.ItemInteraction;
 import brainwine.gameserver.server.PlayerRequest;
-import brainwine.gameserver.util.MapHelper;
 import brainwine.gameserver.zone.Block;
 import brainwine.gameserver.zone.MetaBlock;
 import brainwine.gameserver.zone.Zone;
 
-@SuppressWarnings("unchecked")
 @RequestInfo(id = 21)
 public class BlockUseRequest extends PlayerRequest {
     
@@ -36,10 +28,17 @@ public class BlockUseRequest extends PlayerRequest {
     public void process(Player player) {
         Zone zone = player.getZone();
         
+        // Do nothing if player is dead or if the target chunk is not active
         if(player.isDead() || !player.isChunkActive(x, y)) {
             return;
         }
         
+        // Do nothing if player is too far away
+        if(!player.isGodMode() && !player.inRange(x, y, player.getMiningRange())) {
+            return;
+        }
+        
+        // Transform usage data if necessary
         if(data != null && data.length == 1 && data[0] instanceof Map) {
             data = ((Map<?, ?>)data[0]).values().toArray();
         }
@@ -49,161 +48,40 @@ public class BlockUseRequest extends PlayerRequest {
         Item item = block.getItem(layer);
         int mod = block.getMod(layer);
         
+        // Check if block is owned by another player
         if(metaBlock != null && item.hasUse(ItemUseType.PROTECTED)) {
-            Player owner = GameServer.getInstance().getPlayerManager().getPlayerById(metaBlock.getOwner());
+            Player owner = metaBlock.getOwner();
             
             if(player != owner) {
                 if(item.hasUse(ItemUseType.PUBLIC)) {
                     String publicUse = item.getUse(ItemUseType.PUBLIC).toString();
                     
+                    // TODO implement other cases
                     switch(publicUse) {
-                        case "owner":
-                            player.notify(String.format("This %s is owned by %s.", 
-                                    item.getTitle().toLowerCase(), owner == null ? "nobody.." : owner.getName()));
-                            break;
+                    case "owner":
+                        player.notify(String.format("This %s is owned by %s.", 
+                                item.getTitle().toLowerCase(), owner == null ? "somebody else" : owner.getName()));
+                        break;
+                    case "note":
+                        ItemUseType.NOTE.getInteraction().interact(zone, player, x, y, layer, item, mod, metaBlock, null, data);
+                        break;
+                    default: break;
                     }
                 } else {
                     player.notify("Sorry, that belongs to somebody else.");
-                    return;
                 }
+                
+                return;
             }
         }
         
-        for(Entry<ItemUseType, Object> entry : item.getUses().entrySet()) {
-            ItemUseType use = entry.getKey();
-            Object value = entry.getValue();
+        // Try to interact with the block
+        item.getUses().forEach((use, config) -> {
+            ItemInteraction interaction = use.getInteraction();
             
-            switch(use) {
-            case DIALOG:
-            case CREATE_DIALOG:
-                if(data != null && value instanceof Map) {
-                    Map<String, Object> config = (Map<String, Object>)value;
-                    String target = MapHelper.getString(config, "target", "none");
-                    
-                    switch(target) {
-                    case "meta":
-                        Map<String, Object> metadata = new HashMap<>();
-                        List<Map<String, Object>> sections = MapHelper.getList(config, "sections");
-                        
-                        if(sections != null && data.length == sections.size()) {
-                            for(int i = 0; i < sections.size(); i++) {
-                                Map<String, Object> section = sections.get(i);
-                                String key = MapHelper.getString(section, "input.key");
-                                
-                                if(key != null) {
-                                    String text = String.valueOf(data[i]);
-                                    
-                                    // Get rid of text if player is currently muted
-                                    if(player.isMuted() && MapHelper.getBoolean(section, "input.sanitize")) {
-                                        text = text.replaceAll(".", "*");
-                                    }
-                                    
-                                    metadata.put(key, text);
-                                } else if(MapHelper.getBoolean(section, "input.mod")) {
-                                    List<Object> options = MapHelper.getList(section, "input.options");
-                                    
-                                    if(options != null) {
-                                        mod = options.indexOf(data[i]);
-                                        mod = mod == -1 ? 0 : mod;
-                                        mod *= MapHelper.getInt(section, "input.mod_multiple", 1);
-                                        zone.updateBlock(x, y, layer, item, mod, player);
-                                    }
-                                }
-                            }
-                        }
-                        
-                        // TODO find out what this is for
-                        if(use == ItemUseType.CREATE_DIALOG) {
-                            metadata.put("cd", true);
-                        }
-                        
-                        zone.setMetaBlock(x, y, item, player, metadata);
-                        break;
-                    }
-                }
-                break;
-            case CHANGE:
-                zone.updateBlock(x, y, layer, item, mod == 0 ? 1 : 0, player);
-                break;
-            case CONTAINER:
-                if(metaBlock != null) {
-                    Map<String, Object> metadata = metaBlock.getMetadata();
-                    String specialItem = MapHelper.getString(metadata, "$");
-                    
-                    if(specialItem != null) {
-                        String dungeonId = MapHelper.getString(metadata, "@");
-                        
-                        if(dungeonId != null && item.hasUse(ItemUseType.FIELDABLE) && zone.isDungeonIntact(dungeonId)) {
-                            player.notify("This container is secured by protectors in the area.");
-                            break;
-                        }
-                                                
-                        if(specialItem.equals("?")) {
-                            Loot loot = GameServer.getInstance().getLootManager().getRandomLoot(player, item.getLootCategories());
-                            
-                            if(loot == null) {
-                                player.notify("No eligible loot could be found for this container.");
-                            } else {
-                                metadata.remove("$");
-                                player.awardLoot(loot, item.getLootGraphic());
-                                player.getStatistics().trackContainerLooted(item);
-                            }
-                        } else {
-                            player.notify("Sorry, this container can't be looted right now.");
-                        }
-                        
-                        if(mod != 0) {
-                            zone.updateBlock(x, y, Layer.FRONT, item, 0);
-                        }
-                    }
-                }
-                break;
-            case TELEPORT:
-                if(data != null && mod == 1 && data.length == 2 && data[0] instanceof Integer && data[1] instanceof Integer) {
-                    int tX = (int)data[0];
-                    int tY = (int)data[1];
-                    MetaBlock target = zone.getMetaBlock(tX, tY);
-                    
-                    if(target != null && target.getItem().hasUse(ItemUseType.TELEPORT, ItemUseType.ZONE_TELEPORT)) {
-                        player.teleport(tX + 1, tY);
-                    }
-                } else if(mod == 0) {
-                    zone.updateBlock(x, y, layer, item, 1);
-                    player.getStatistics().trackDiscovery(item);
-                    player.notify("You repaired a teleporter!", NotificationType.ACCOMPLISHMENT);
-                    player.notifyPeers(String.format("%s repaired a teleporter.", player.getName()), NotificationType.SYSTEM);
-                }
-                break;
-            case SWITCH:
-                if(data == null) {
-                    if(metaBlock != null) {
-                        // TODO timed switches
-                        Map<String, Object> metadata = metaBlock.getMetadata();
-                        List<List<Integer>> positions = MapHelper.getList(metadata, ">", Collections.emptyList());
-                        zone.updateBlock(x, y, layer, item, mod % 2 == 0 ? mod + 1 : mod - 1, player, metadata);
-                        
-                        for(List<Integer> position : positions) {
-                            int sX = position.get(0);
-                            int sY = position.get(1);
-                            Block target = zone.getBlock(sX, sY);
-                            
-                            if(target != null) {
-                                Item switchedItem = target.getFrontItem();
-                                
-                                if(switchedItem.hasUse(ItemUseType.SWITCHED)) {
-                                    if(!(item.getUse(ItemUseType.SWITCHED) instanceof String)) {
-                                        int switchedMod = target.getFrontMod();
-                                        zone.updateBlock(sX, sY, Layer.FRONT, switchedItem, switchedMod % 2 == 0 ? switchedMod + 1 : switchedMod - 1, null);
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-                break;
-            default:
-                break;
+            if(interaction != null) {
+                interaction.interact(zone, player, x, y, layer, item, mod, metaBlock, config, data);
             }
-        }
+        });
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java
index 706a8bb..3a00167 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java
@@ -47,6 +47,13 @@ public class BlocksRequest extends PlayerRequest {
             }
             
             Chunk chunk = zone.getChunk(index);
+            
+            // Kick player if chunk is null (load failure)
+            if(chunk == null) {
+                player.kick("Chunk load failure.");
+                return;
+            }
+            
             chunks.add(chunk);
             metaBlocks.addAll(zone.getLocalMetaBlocksInChunk(index));
             player.addActiveChunk(index);
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java
index beb7de2..6c903af 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java
@@ -5,29 +5,28 @@ import java.util.Map.Entry;
 
 import brainwine.gameserver.annotations.RequestInfo;
 import brainwine.gameserver.dialog.DialogHelper;
-import brainwine.gameserver.entity.player.ClothingSlot;
-import brainwine.gameserver.entity.player.ColorSlot;
+import brainwine.gameserver.entity.player.Appearance;
+import brainwine.gameserver.entity.player.AppearanceSlot;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.ItemRegistry;
 import brainwine.gameserver.server.PlayerRequest;
+import brainwine.gameserver.server.messages.EntityChangeMessage;
+import brainwine.gameserver.util.MapHelper;
 
-/**
- * TODO we should actually check if the sent value is even compatible with the slot.
- * We wouldn't want to allow players to equip pants for hats!
- */
 @RequestInfo(id = 22)
 public class ChangeAppearanceRequest extends PlayerRequest {
     
-    public Map<String, Object> data;
+    public Map<String, Object> appearance;
     
     @Override
     public void process(Player player) {
-        if(data.containsKey("meta")) {
-            String meta = "" + data.get("meta");
+        // Handle special cases
+        if(appearance.containsKey("meta")) {
+            String meta = MapHelper.getString(appearance, "meta", "");
 
             if(meta.equals("randomize")) {
-                player.notify("Sorry, you can't randomize your appearance yet.");
+                player.randomizeAppearance();
             } else {
                 player.showDialog(DialogHelper.getWardrobeDialog(meta));
             }
@@ -35,36 +34,54 @@ public class ChangeAppearanceRequest extends PlayerRequest {
             return;
         }
         
-        for(Entry<String, Object> entry : data.entrySet()) {
-            String key = entry.getKey();
+        // Validate appearance data
+        for(Entry<String, Object> entry : appearance.entrySet()) {
+            AppearanceSlot slot = AppearanceSlot.fromId(entry.getKey());
             Object value = entry.getValue();
             
-            if(value instanceof Integer) {
-                ClothingSlot slot = ClothingSlot.fromId(key);
-                
-                if(slot == null) {
-                    continue;
-                }
-                
-                Item item = ItemRegistry.getItem((int)value);
-                
-                if(!item.isBase() && !player.getInventory().hasItem(item)) {
-                    player.notify("Sorry, but you do not own this.");
+            // Fail if slot is not valid
+            if(slot == null || !slot.isChangeable()) {
+                fail(player);
+                return;
+            }
+            
+            // Handle color data
+            if(slot.isColor()) {
+                // Fail if color value is not a string
+                if(!(value instanceof String)) {
+                    fail(player);
                     return;
                 }
                 
-                player.setClothing(slot, item);
-            } else if(value instanceof String) {
-                // TODO check if player owns color
-                ColorSlot slot = ColorSlot.fromId(key);
-                String color = (String)value;
-                
-                if(slot == null) {
-                    continue;
+                // Fail if player doesn't own color
+                if(!Appearance.getAvailableColors(slot, player).contains((String)value)) {
+                    fail(player);
+                    return;
                 }
                 
-                player.setColor(slot, color);
+                continue;
+            }
+            
+            // Fail if item value is not an integer (item code)
+            if(!(value instanceof Integer)) {
+                fail(player);
+                return;
+            }
+            
+            Item item = ItemRegistry.getItem((int)value);
+            
+            // Do nothing if item isn't valid clothing or player doesn't own it
+            if(!item.isClothing() || !slot.getCategory().equals(item.getCategory()) || (!item.isBase() && !player.getInventory().hasItem(item))) {
+                fail(player);
+                return;
             }
         }
+        
+        // Update player appearance
+        player.updateAppearance(appearance);
+    }
+    
+    private void fail(Player player) {
+        player.sendMessage(new EntityChangeMessage(player.getId(), player.getAppearance()));
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java
index 267244b..230f932 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java
@@ -2,7 +2,7 @@ package brainwine.gameserver.server.requests;
 
 import brainwine.gameserver.annotations.OptionalField;
 import brainwine.gameserver.annotations.RequestInfo;
-import brainwine.gameserver.command.CommandManager;
+import brainwine.gameserver.commands.CommandManager;
 import brainwine.gameserver.entity.player.NotificationType;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.server.PlayerRequest;
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/ConsoleRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/ConsoleRequest.java
index 4ff46b9..022e223 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/ConsoleRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/ConsoleRequest.java
@@ -1,7 +1,7 @@
 package brainwine.gameserver.server.requests;
 
 import brainwine.gameserver.annotations.RequestInfo;
-import brainwine.gameserver.command.CommandManager;
+import brainwine.gameserver.commands.CommandManager;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.server.PlayerRequest;
 
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java
index 4e4f9ea..b5f9454 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java
@@ -1,6 +1,7 @@
 package brainwine.gameserver.server.requests;
 
 import java.util.List;
+import java.util.stream.Collectors;
 
 import brainwine.gameserver.annotations.OptionalField;
 import brainwine.gameserver.annotations.RequestInfo;
@@ -13,10 +14,8 @@ import brainwine.gameserver.server.PlayerRequest;
 import brainwine.gameserver.util.MathUtils;
 import brainwine.gameserver.util.Pair;
 import brainwine.gameserver.zone.MetaBlock;
+import brainwine.gameserver.zone.Zone;
 
-/**
- * TODO Account for skills, bonuses etc..
- */
 @RequestInfo(id = 19)
 public class CraftRequest extends PlayerRequest {
     
@@ -54,21 +53,43 @@ public class CraftRequest extends PlayerRequest {
                 return;
             }
         }
-        
+
         // Check if required crafting helpers are nearby
         if(!player.isGodMode() && item.requiresWorkshop()) {
-            List<MetaBlock> workshop = player.getZone().getMetaBlocks(metaBlock
-                    -> MathUtils.inRange(player.getX(), player.getY(), metaBlock.getX(), metaBlock.getY(), 10));
+            Zone zone = player.getZone();
             
+            // Fetch list of all meta blocks in the player's vicinity
+            List<MetaBlock> workshop = zone.getMetaBlocks(metaBlock -> zone.isChunkLoaded(metaBlock.getX(), metaBlock.getY())
+                    && MathUtils.inRange(player.getX(), player.getY(), metaBlock.getX(), metaBlock.getY(), 20));
+            
+            // Check for each crafting helper if it is present in the workshop and available for use
             for(CraftingRequirement craftingHelper : item.getCraftingHelpers()) {
-                int quantityMissing = craftingHelper.getQuantity() - (int)workshop.stream().filter(metaBlock
-                        -> metaBlock.getItem() == craftingHelper.getItem()).count();
+                int quantityRequired = craftingHelper.getQuantity();
                 
+                // Fetch list of crafting helpers of this type that are present in the workshop
+                List<MetaBlock> presentCraftingHelpers = workshop.stream()
+                        .filter(metaBlock -> metaBlock.getItem() == craftingHelper.getItem()).collect(Collectors.toList());
+                int quantityMissing = quantityRequired - presentCraftingHelpers.size();
+                
+                // Check if workshop is still missing crafting helpers of this type and notify the player if this is the case
                 if(quantityMissing > 0) {
-                    player.notify(String.format("You can't craft this item because your workshop is lacking %sx %s.",
+                    player.notify(String.format("You can't craft this item because your workshop is still lacking %sx %s.",
                             quantityMissing, craftingHelper.getItem().getTitle()));
                     return;
                 }
+
+                // Perform additional checks if the crafting helper requires steam to function
+                if(craftingHelper.getItem().usesSteam()) {
+                    quantityMissing = quantityRequired - (int)presentCraftingHelpers.stream()
+                            .filter(metaBlock -> zone.getBlock(metaBlock.getX(), metaBlock.getY()).getFrontMod() == 1).count();
+                    
+                    // Notify the player if not enough crafting helpers are powered
+                    if(quantityMissing > 0) {
+                        player.notify(String.format("You can't craft this item because your workshop still needs to provide steam power to %sx %s.",
+                                quantityMissing, craftingHelper.getItem().getTitle()));
+                        return;
+                    }
+                }
             }
         }
         
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java
index 44e1b21..e40904c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java
@@ -73,10 +73,11 @@ public class DialogRequest extends PlayerRequest {
                                 "Note: Additional skills like Combat and Engineering are unlocked as you progress." : null)
                         .setInput(new DialogSelectInput()
                                 .setOptions(upgradeableSkillNames)
+                                .setMaxColumns(3)
                                 .setKey("skill")));
         
         player.showDialog(dialog, input -> {
-            if(input.length == 0) {
+            if(input.length == 0 || input[0].equals("cancel")) {
                 return;
             }
             
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/HealthRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/HealthRequest.java
index b1e0e94..4929b31 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/HealthRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/HealthRequest.java
@@ -2,7 +2,10 @@ package brainwine.gameserver.server.requests;
 
 import brainwine.gameserver.annotations.OptionalField;
 import brainwine.gameserver.annotations.RequestInfo;
+import brainwine.gameserver.entity.Entity;
 import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.DamageType;
+import brainwine.gameserver.item.Item;
 import brainwine.gameserver.server.PlayerRequest;
 
 @RequestInfo(id = 18)
@@ -18,10 +21,18 @@ public class HealthRequest extends PlayerRequest {
     public void process(Player player) {
         float health = this.health / 1000.0F;
         
-        if(!player.isGodMode() && health >= player.getHealth()) {
+        // Prevent self-healing unless player has god mode enabled
+        if(health >= player.getHealth()) {
+            if(player.isGodMode()) {
+                player.setHealth(health);
+            }
+            
             return;
         }
         
-        player.damage(player.getHealth() - health, null);
+        // TODO attacker ID is always zero on v3 and damage type seems to do nothing on both v2 and v3 so we'll just have to do what we can here
+        Entity attacker = player.getZone().getEntity(attackerId);
+        float damage = player.getHealth() - health;
+        player.attack(attacker, Item.AIR, damage, DamageType.ACID, true); // Deal true damage; the client should have already applied any damage modifiers
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java
index 7847ebc..a7a6b4e 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java
@@ -5,6 +5,7 @@ import java.util.Collection;
 
 import brainwine.gameserver.annotations.OptionalField;
 import brainwine.gameserver.annotations.RequestInfo;
+import brainwine.gameserver.entity.Entity;
 import brainwine.gameserver.entity.npc.Npc;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
@@ -29,14 +30,14 @@ public class InventoryUseRequest extends PlayerRequest {
     @Override
     public void process(Player player) {
         // Don't do anything if the player is dead or doesn't own this item
-        if(player.isDead() || !player.getInventory().hasItem(item)) {
+        if(player.isDead() || (!item.isAir() && !player.getInventory().hasItem(item))) {
             return;
         }
         
         // Try to consume item if it is a consumable
         if(item.isConsumable()) {
             if(status == 1) {
-                player.consume(item);
+                player.consume(item, details);
             }
         } else {
             // Set current held item if applicable
@@ -45,7 +46,7 @@ public class InventoryUseRequest extends PlayerRequest {
             }
             
             // Send item use data to other players in the zone
-            player.sendMessageToPeers(new EntityItemUseMessage(player.getId(), type, item, status));
+            player.sendMessageToTrackers(new EntityItemUseMessage(player.getId(), type, item, status));
             
             // Lovely type ambiguity. Always nice.
             if(item.isWeapon() && status == 1) {
@@ -63,8 +64,8 @@ public class InventoryUseRequest extends PlayerRequest {
                     if(id instanceof Integer) {
                         Npc npc = player.getZone().getNpc((int)id);
                         
-                        if(npc != null && (player.isGodMode() || player.canSee(npc))) {
-                            npc.attack(player, item);
+                        if(npc != null && (player.isGodMode() || (player.canSee(npc) && !npc.wasAttackedRecently(player, Entity.ATTACK_INVINCIBLE_TIME)))) {
+                            npc.attack(player, item, item.getDamage(), item.getDamageType());
                         }
                     }
                     
diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/ZoneChangeRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/ZoneChangeRequest.java
index cbf048e..ccedb62 100644
--- a/gameserver/src/main/java/brainwine/gameserver/server/requests/ZoneChangeRequest.java
+++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/ZoneChangeRequest.java
@@ -23,6 +23,18 @@ public class ZoneChangeRequest extends PlayerRequest {
             return;
         }
         
+        // Check survival requirement unless player has god mode enabled
+        /*
+        if(!player.isGodMode()) {
+            Biome biome = zone.getBiome();
+            int survival = MapHelper.getInt(GameConfiguration.getBaseConfig(), String.format("biomes.%s.survival_requirement", biome.getId()));
+            
+            if(player.getTotalSkillLevel(Skill.SURVIVAL) < survival) {
+                player.notify(String.format("Your survival skill needs to be at least level %s to enter %s worlds.", survival, biome.getId()));
+                return;
+            }
+        }*/
+        
         player.changeZone(zone);
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/util/MapHelper.java b/gameserver/src/main/java/brainwine/gameserver/util/MapHelper.java
index 5f0b88e..4890cf2 100644
--- a/gameserver/src/main/java/brainwine/gameserver/util/MapHelper.java
+++ b/gameserver/src/main/java/brainwine/gameserver/util/MapHelper.java
@@ -50,6 +50,10 @@ public class MapHelper {
     }
     
     public static void put(Map<?, ?> map, String path, Object value) {
+        if(path == null) {
+            return;
+        }
+        
         String[] segments = path.split("\\.");
         Map<Object, Object> current = (Map<Object, Object>)map;
         
@@ -76,6 +80,10 @@ public class MapHelper {
     }
     
     public static <T> T get(Map<?, ?> map, String path, Class<T> type, T def) {
+        if(path == null) {
+            return def;
+        }
+        
         String[] segments = path.split("\\.");
         Map<Object, Object> current = (Map<Object, Object>)map;
         
diff --git a/gameserver/src/main/java/brainwine/gameserver/util/MathUtils.java b/gameserver/src/main/java/brainwine/gameserver/util/MathUtils.java
index f7d68e7..508c356 100644
--- a/gameserver/src/main/java/brainwine/gameserver/util/MathUtils.java
+++ b/gameserver/src/main/java/brainwine/gameserver/util/MathUtils.java
@@ -34,7 +34,11 @@ public class MathUtils {
         return clamp(value, 0.0F, 1.0F);
     }
     
+    public static double distance(double x, double y, double x2, double y2) {
+        return Math.hypot(x - x2, y - y2);
+    }
+    
     public static boolean inRange(double x, double y, double x2, double y2, double range) {
-        return Math.hypot(x - x2, y - y2) <= range;
+        return distance(x, y, x2, y2) <= range;
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Block.java b/gameserver/src/main/java/brainwine/gameserver/zone/Block.java
index 5ca3c8c..aa71fbc 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/Block.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/Block.java
@@ -4,40 +4,35 @@ import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.ItemRegistry;
 import brainwine.gameserver.item.Layer;
 
-/**
- * TODO store block owners.
- */
 public class Block {
     
     private Item baseItem;
     private Item backItem;
-    private int backMod;
+    private byte backMod;
     private Item frontItem;
-    private int frontMod;
+    private byte frontMod;
     private Item liquidItem;
-    private int liquidMod;
+    private byte liquidMod;
+    private short ownerHash;
     
     public Block() {
-        this(0, 0, 0, 0, 0, 0, 0);
+        this(0, 0, 0, 0, 0, 0, 0, 0);
     }
     
     public Block(int base, int back, int front) {
-        this(base & 15, back & 65535, back >> 16 & 31, front & 65535, front >> 16 & 31, base >> 8 & 255, base >> 16 & 31);
+        this(base & 15, back & 65535, back >> 16 & 31, front & 65535, front >> 16 & 31, base >> 8 & 255, base >> 16 & 31, front >> 21 & 2047);
     }
     
-    public Block(int baseItem, int backItem, int backMod, int frontItem, int frontMod, int liquidItem, int liquidMod) {
+    public Block(int baseItem, int backItem, int backMod, int frontItem, int frontMod, int liquidItem, int liquidMod, int ownerHash) {
         this(ItemRegistry.getItem(baseItem), ItemRegistry.getItem(backItem), backMod, 
-                ItemRegistry.getItem(frontItem), frontMod, ItemRegistry.getItem(liquidItem), liquidMod);
+                ItemRegistry.getItem(frontItem), frontMod, ItemRegistry.getItem(liquidItem), liquidMod, ownerHash);
     }
     
-    public Block(Item baseItem, Item backItem, int backMod, Item frontItem, int frontMod, Item liquidItem, int liquidMod) {
-        this.baseItem = baseItem;
-        this.backItem = backItem;
-        this.backMod = backMod;
-        this.frontItem = frontItem;
-        this.frontMod = frontMod;
-        this.liquidItem = liquidItem;
-        this.liquidMod = liquidMod;
+    public Block(Item baseItem, Item backItem, int backMod, Item frontItem, int frontMod, Item liquidItem, int liquidMod, int ownerHash) {
+        updateLayer(Layer.BASE, baseItem, 0, ownerHash);
+        updateLayer(Layer.BACK, backItem, backMod, ownerHash);
+        updateLayer(Layer.FRONT, frontItem, frontMod, ownerHash);
+        updateLayer(Layer.LIQUID, liquidItem, liquidMod, ownerHash);
     }
     
     public void updateLayer(Layer layer, int item) {
@@ -45,7 +40,11 @@ public class Block {
     }
     
     public void updateLayer(Layer layer, int item, int mod) {
-        updateLayer(layer, ItemRegistry.getItem(item), mod);
+        updateLayer(layer, item, mod, 0);
+    }
+    
+    public void updateLayer(Layer layer, int item, int mod, int owner) {
+        updateLayer(layer, ItemRegistry.getItem(item), mod, owner);
     }
     
     public void updateLayer(Layer layer, Item item) {
@@ -53,21 +52,26 @@ public class Block {
     }
     
     public void updateLayer(Layer layer, Item item, int mod) {
+        updateLayer(layer, item, mod, 0);
+    }
+    
+    public void updateLayer(Layer layer, Item item, int mod, int owner) {
         switch(layer) {
         case BASE:
             baseItem = item;
             break;
         case BACK:
             backItem = item;
-            backMod = mod;
+            backMod = (byte)(mod & 31);
             break;
         case FRONT:
             frontItem = item;
-            frontMod = mod;
+            frontMod = (byte)(mod & 31);
+            ownerHash = (short)(item.isAir() ? 0 : owner & 2047);
             break;
         case LIQUID:
             liquidItem = item;
-            liquidMod = mod;
+            liquidMod = (byte)(mod & 31);
             break;
         default:
             break;
@@ -115,13 +119,13 @@ public class Block {
     public void setMod(Layer layer, int mod) {
         switch(layer) {
         case BACK:
-            backMod = mod;
+            backMod = (byte)(mod & 31);
             break;
         case FRONT:
-            frontMod = mod;
+            frontMod = (byte)(mod & 31);
             break;
         case LIQUID:
-            liquidMod = mod;
+            liquidMod = (byte)(mod & 31);
             break;
         default:
             break;
@@ -170,7 +174,7 @@ public class Block {
     }
     
     public int getFront() {
-        return frontItem.getCode() | ((frontMod & 31) << 16);
+        return frontItem.getCode() | ((ownerHash & 2047) << 21) | ((frontMod & 31) << 16);
     }
     
     public Item getLiquidItem() {
@@ -180,4 +184,12 @@ public class Block {
     public int getLiquidMod() {
         return liquidMod;
     }
+    
+    public boolean isNatural() {
+        return ownerHash == 0;
+    }
+    
+    public int getOwnerHash() {
+        return ownerHash;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java b/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java
index 954ddd7..e594e62 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java
@@ -16,6 +16,7 @@ public class Chunk {
     private final int width;
     private final int height;
     private final Block[] blocks;
+    private long saveTime;
     private boolean modified;
     
     @ConstructorProperties({"x", "y", "width", "height", "blocks"})
@@ -83,4 +84,13 @@ public class Chunk {
     public Block[] getBlocks() {
         return blocks;
     }
+    
+    public void setSaveTime(long saveTime) {
+        this.saveTime = saveTime;
+    }
+    
+    @JsonIgnore
+    public long getSaveTime() {
+        return saveTime;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java
index d299dd3..4417c71 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java
@@ -17,11 +17,10 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.zip.DataFormatException;
 
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
-import org.msgpack.core.MessagePack;
-import org.msgpack.core.MessageUnpacker;
 import org.msgpack.jackson.dataformat.MessagePackFactory;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -34,66 +33,102 @@ import brainwine.gameserver.util.ZipUtils;
 
 public class ChunkManager {
 
+    public static final int FILE_SIGNATURE = 0x44574344;
+    public static final int FILE_HEADER_SIZE = 64;
+    public static final int LATEST_FILE_VERSION = 0x00000001;
+    public static final int DEFAULT_CHUNK_ALLOC_SIZE = 2048;
+    public static final int CHUNK_HEADER_SIZE = 32;
+    public static final byte[] FILE_HEADER_PADDING = new byte[FILE_HEADER_SIZE - 12];
+    public static final byte[] CHUNK_HEADER_PADDING = new byte[CHUNK_HEADER_SIZE - 12];
     private static final Logger logger = LogManager.getLogger();
-    private static final int allocSize = 2048;
     private static final ObjectMapper mapper = new ObjectMapper(new MessagePackFactory())
             .registerModule(new SimpleModule()
                     .addDeserializer(Block.class, BlockDeserializer.INSTANCE)
                     .addSerializer(BlockSerializer.INSTANCE));
     private final Map<Integer, Chunk> chunks = new HashMap<>();
     private final Zone zone;
-    private final File blocksFile;
     private RandomAccessFile file;
-    private int dataOffset;
+    private int allocSize;
     
     public ChunkManager(Zone zone) {
         this.zone = zone;
-        blocksFile = new File(zone.getDirectory(), "blocks.dat");
-        File legacyBlocksFile = new File(zone.getDirectory(), "blocks");
+    }
+    
+    private void initialize() throws IOException, DataFormatException {
+        // Do nothing if already initialized
+        if(file != null) {
+            return;
+        }
         
-        if(!blocksFile.exists() && legacyBlocksFile.exists()) {
-            logger.info(SERVER_MARKER, "Updating blocks file for zone {} ...", zone.getDocumentId());
-            DataInputStream inputStream = null;
-            DataOutputStream outputStream = null;
-            
-            try {
-                inputStream = new DataInputStream(new FileInputStream(legacyBlocksFile));
-                outputStream = new DataOutputStream(new FileOutputStream(blocksFile));
-                int chunkCount = zone.getChunkCount();
-                
-                for(int i = 0; i < chunkCount; i++) {
-                    short length = inputStream.readShort();
-                    byte[] chunkBytes = new byte[length];
-                    inputStream.read(chunkBytes);
-                    inputStream.skipBytes(2048 - length - 2);
-                    chunkBytes = ZipUtils.inflateBytes(chunkBytes); 
-                    MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(chunkBytes);
-                    unpacker.unpackArrayHeader();
-                    int x = unpacker.unpackInt();
-                    int y = unpacker.unpackInt();
-                    int width = unpacker.unpackInt();
-                    int height = unpacker.unpackInt();
-                    Block[] blocks = new Block[unpacker.unpackArrayHeader() / 3];
-                    
-                    for(int j = 0; j < blocks.length; j++) {
-                        blocks[j] = new Block(unpacker.unpackInt(), unpacker.unpackInt(), unpacker.unpackInt());
-                    }
-                    
-                    unpacker.close();
-                    byte[] bytes = ZipUtils.deflateBytes(mapper.writeValueAsBytes(new Chunk(x, y, width, height, blocks)));
-                    outputStream.writeShort(bytes.length);
-                    outputStream.write(bytes);
-                    outputStream.write(new byte[allocSize - bytes.length - 2]);
+        File dataDirectory = zone.getDirectory();
+        File chunksFileV1 = new File(dataDirectory, "blocks"); // Legacy version (no longer supported)
+        File chunksFileV2 = new File(dataDirectory, "blocks.dat"); // Previous version
+        File chunksFile = new File(dataDirectory, "chunks.bin"); // Latest version
+        
+        // Check outdated legacy format
+        if(!chunksFileV2.exists() && chunksFileV1.exists()) {
+            throw new IOException("Chunk data is outdated. Please try to load this zone with an older server version to update it.");
+        }
+        
+        // Load or initialize header data
+        if(chunksFile.exists()) {
+            try(DataInputStream inputStream = new DataInputStream(new FileInputStream(chunksFile))) {
+                // Check file signature
+                if(inputStream.readInt() != FILE_SIGNATURE) {
+                    throw new IOException("Invalid file signature");
                 }
                 
-                inputStream.close();
-                outputStream.close();
-            } catch(Exception e) {
-                logger.error(SERVER_MARKER, "Could not update blocks file for zone {}", zone.getDocumentId(), e);
+                int fileVersion = inputStream.readInt();
+                allocSize = inputStream.readInt();
+                inputStream.skip(FILE_HEADER_PADDING.length);
+                
+                // Update chunk data if necessary
+                if(fileVersion != LATEST_FILE_VERSION) {
+                    throw new IOException("Invalid file version"); // Throw exception for now since there is only one version
+                }
             }
+        } else {
+            allocSize = DEFAULT_CHUNK_ALLOC_SIZE;
             
-            legacyBlocksFile.delete();
+            try(DataOutputStream outputStream = new DataOutputStream(new FileOutputStream(chunksFile))) {
+                outputStream.writeInt(FILE_SIGNATURE);
+                outputStream.writeInt(LATEST_FILE_VERSION);
+                outputStream.writeInt(allocSize);
+                outputStream.write(FILE_HEADER_PADDING);
+                
+                // Update chunk data from previous version if it is present
+                if(chunksFileV2.exists()) {
+                    logger.info(SERVER_MARKER, "Updating chunk data for zone {} ...", zone.getDocumentId());
+                    int chunkCount = zone.getChunkCount();
+                    long now = System.currentTimeMillis();
+                    
+                    try(DataInputStream inputStream = new DataInputStream(new FileInputStream(chunksFileV2))) {                        
+                        for(int i = 0; i < chunkCount; i++) {
+                            // Read chunk data
+                            byte[] chunkBytes = new byte[inputStream.readShort()];
+                            inputStream.read(chunkBytes);
+                            inputStream.skip(DEFAULT_CHUNK_ALLOC_SIZE - chunkBytes.length - 2); // Skip reserved chunk space
+                            
+                            // Write chunk header
+                            outputStream.writeLong(now); // Save time
+                            outputStream.writeInt(chunkBytes.length);
+                            outputStream.write(CHUNK_HEADER_PADDING);
+                            
+                            // Write chunk data
+                            outputStream.write(chunkBytes);
+                            
+                            // Write chunk padding
+                            if(i + 1 < chunkCount) {
+                                outputStream.write(new byte[allocSize - chunkBytes.length - CHUNK_HEADER_SIZE]);
+                            }
+                        }
+                    }
+                }
+            }
         }
+        
+        // Create random access file stream
+        file = new RandomAccessFile(chunksFile, "rw");
     }
     
     protected void closeStream() {
@@ -112,10 +147,7 @@ public class ChunkManager {
         List<Chunk> inactiveChunks = new ArrayList<>();
         
         for(Chunk chunk : chunks.values()) {
-            if(chunk.isModified()) {
-                saveChunk(chunk);
-            }
-            
+            saveChunk(chunk);
             boolean active = false;
             
             for(Player player : zone.getPlayers()) {
@@ -140,35 +172,40 @@ public class ChunkManager {
         int index = zone.getChunkIndex(chunk.getX(), chunk.getY());
         
         try {
-            if(file == null) {
-                file = new RandomAccessFile(blocksFile, "rw");
+            initialize();
+            file.seek(FILE_HEADER_SIZE + index * allocSize);
+            file.writeLong(System.currentTimeMillis()); // Write save time
+            
+            // Write block data if chunk has been modified
+            if(chunk.isModified()) {
+                byte[] bytes = ZipUtils.deflateBytes(mapper.writeValueAsBytes(chunk));
+                
+                // TODO reformat entire file with bigger alloc size
+                if(bytes.length > allocSize - CHUNK_HEADER_SIZE) {
+                    throw new IOException("WARNING: bigger than alloc size: " + bytes.length);
+                }
+                
+                file.writeInt(bytes.length);
+                file.write(CHUNK_HEADER_PADDING);
+                file.write(bytes);
+                chunk.setModified(false);
             }
-            
-            byte[] bytes = ZipUtils.deflateBytes(mapper.writeValueAsBytes(chunk));
-            
-            if(bytes.length > allocSize) {
-                throw new IOException("WARNING: bigger than alloc size: " + bytes.length);
-            }
-            
-            file.seek(dataOffset + index * allocSize);
-            file.writeShort(bytes.length);
-            file.write(bytes);
-            chunk.setModified(false);
-        } catch (IOException e) {
+        } catch(Exception e) {
             logger.error(SERVER_MARKER, "Could not save chunk {} of zone {}", index, zone.getDocumentId(), e);
         }
     }
     
     private Chunk loadChunk(int index) {
         try {
-            if(file == null) {
-                file = new RandomAccessFile(blocksFile, "rw");
-            }
-            
-            file.seek(dataOffset + index * allocSize);
-            byte[] bytes = new byte[file.readShort()];
+            initialize();
+            file.seek(FILE_HEADER_SIZE + index * allocSize);
+            long saveTime = file.readLong();
+            byte[] bytes = new byte[file.readInt()];
+            file.skipBytes(CHUNK_HEADER_PADDING.length);
             file.read(bytes);
-            return mapper.readValue(ZipUtils.inflateBytes(bytes), Chunk.class);
+            Chunk chunk = mapper.readValue(ZipUtils.inflateBytes(bytes), Chunk.class);
+            chunk.setSaveTime(saveTime);
+            return chunk;
         } catch(Exception e) {
             logger.error(SERVER_MARKER, "Could not load chunk {} of zone {}", index, zone.getDocumentId(), e);
         }
@@ -237,10 +274,15 @@ public class ChunkManager {
         
         Chunk chunk = chunks.get(index);
         
+        // Load chunk if it isn't cached
         if(chunk == null) {
             chunk = loadChunk(index);
-            chunks.put(index, chunk);
-            zone.onChunkLoaded(chunk);
+            
+            // Index chunk if it was loaded successfully
+            if(chunk != null) {
+                chunks.put(index, chunk);
+                zone.onChunkLoaded(chunk);
+            }
         }
         
         return chunk;
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java
index 0b71f8d..a39a046 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java
@@ -20,17 +20,16 @@ import org.apache.logging.log4j.Logger;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 
-import brainwine.gameserver.GameServer;
 import brainwine.gameserver.entity.Entity;
 import brainwine.gameserver.entity.EntityConfig;
 import brainwine.gameserver.entity.EntityRegistry;
 import brainwine.gameserver.entity.EntityStatus;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.NpcData;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.item.ModType;
-import brainwine.gameserver.server.messages.EffectMessage;
 import brainwine.gameserver.server.messages.EntityPositionMessage;
 import brainwine.gameserver.server.messages.EntityStatusMessage;
 import brainwine.gameserver.util.MapHelper;
@@ -175,8 +174,8 @@ public class EntityManager {
     
     private void clearEntities() {
         npcs.values().stream()
-            .filter(npc -> npc.isDead() || !zone.isChunkLoaded((int)npc.getX(), (int)npc.getY()) ||
-                    (npc.isTransient() && System.currentTimeMillis() > npc.getLastTrackedAt() + ENTITY_CLEAR_TIME))
+            .filter(npc -> npc.isDead() || (!npc.isPersistent() && (!zone.isChunkLoaded(npc.getBlockX(), npc.getBlockY()) ||
+                    (npc.isTransient() && System.currentTimeMillis() > npc.getLastTrackedAt() + ENTITY_CLEAR_TIME))))
             .collect(Collectors.toList())
             .forEach(this::removeEntity);
     }
@@ -210,12 +209,10 @@ public class EntityManager {
                 List<String> guardians = MapHelper.getList(metaBlock.getMetadata(), "!", Collections.emptyList());
                 
                 for(String guardian : guardians) {
-                    EntityConfig config = EntityRegistry.getEntityConfig(guardian);
+                    Npc entity = spawnEntity(guardian, x, y);
                     
-                    if(config != null) {
-                        Npc entity = new Npc(zone, config);
+                    if(entity != null) {
                         entity.setGuardBlock(x, y);
-                        spawnEntity(entity, x, y);
                     }
                 }
             }
@@ -231,36 +228,60 @@ public class EntityManager {
         
         // Check for mounted entity (turrets & geysers)
         if(item.isEntity()) {
-            EntityConfig config = EntityRegistry.getEntityConfig(item.getId());
+            Npc entity = spawnEntity(item.getId(), x, y);
             
-            if(config != null) {
-                Npc entity = new Npc(zone, config);
+            if(entity != null) {
                 MetaBlock metaBlock = zone.getMetaBlock(x, y);
                 
                 // Set owner entity if it has one
                 if(metaBlock != null && metaBlock.hasOwner()) {
-                    entity.setOwner(GameServer.getInstance().getPlayerManager().getPlayerById(metaBlock.getOwner()));
+                    entity.setOwner(metaBlock.getOwner());
                 }
                 
                 entity.setMountBlock(x, y);
-                spawnEntity(entity, x, y);
                 mountedNpcs.put(index, entity);
             }
         }
     }
     
+    public void spawnPersistentNpcs(Collection<NpcData> data) {
+        for(NpcData entry : data) {
+            if(entry.getType() == null) {
+                continue;
+            }
+            
+            Npc npc = new Npc(zone, entry.getType());
+            npc.setName(entry.getName());
+            spawnEntity(npc, entry.getX(), entry.getY());
+        }
+    }
+    
+    public Npc spawnEntity(String type, int x, int y) {
+        return spawnEntity(type, x, y, false);
+    }
+    
+    public Npc spawnEntity(String type, int x, int y, boolean effect) {
+        EntityConfig config = EntityRegistry.getEntityConfig(type);
+        
+        if(config == null) {
+            return null;
+        }
+        
+        Npc entity = new Npc(zone, config);
+        spawnEntity(entity, x, y, effect);
+        return entity;
+    }
+    
     public void spawnEntity(Entity entity, int x, int y) {
         spawnEntity(entity, x, y, false);
     }
     
     public void spawnEntity(Entity entity, int x, int y, boolean effect) {
-        if(zone.isChunkLoaded(x, y)) {
-            addEntity(entity);
-            entity.setPosition(x, y);
-            
-            if(effect) {
-                zone.sendMessageToChunk(new EffectMessage(x + 0.5F, y + 0.5F, "bomb-teleport", 4), zone.getChunk(x, y));
-            }
+        addEntity(entity);
+        entity.setPosition(x, y);
+        
+        if(effect && zone.isChunkLoaded(x, y)) {
+            zone.spawnEffect(x + 0.5F, y + 0.5F, "bomb-teleport", 4);
         }
     }
     
@@ -332,13 +353,17 @@ public class EntityManager {
     }
     
     public int getTransientNpcCount() {
-        return (int)npcs.values().stream().filter(npc -> npc.isTransient()).count();
+        return (int)npcs.values().stream().filter(Npc::isTransient).count();
     }
     
     public Collection<Npc> getNpcs() {
         return Collections.unmodifiableCollection(npcs.values());
     }
     
+    public List<Npc> getPersistentNpcs() {
+        return npcs.values().stream().filter(Npc::isPersistent).collect(Collectors.toList());
+    }
+    
     public Player getPlayer(int entityId) {
         return players.get(entityId);
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/MetaBlock.java b/gameserver/src/main/java/brainwine/gameserver/zone/MetaBlock.java
index f16ae05..b4f53e7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/MetaBlock.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/MetaBlock.java
@@ -2,6 +2,7 @@ package brainwine.gameserver.zone;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.function.Function;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -10,9 +11,13 @@ import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
 import com.fasterxml.jackson.annotation.JsonProperty;
 
+import brainwine.gameserver.GameServer;
 import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.item.Item;
 
+/**
+ * I hate this class and everything in it.
+ */
 @JsonInclude(Include.NON_NULL)
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class MetaBlock {
@@ -67,7 +72,17 @@ public class MetaBlock {
         return owner != null;
     }
     
-    public String getOwner() {
+    public boolean isOwnedBy(Player player) {
+        return player != null && player.getDocumentId().equals(owner);
+    }
+    
+    @JsonIgnore
+    public Player getOwner() {
+        return GameServer.getInstance().getPlayerManager().getPlayerById(owner);
+    }
+    
+    @JsonProperty("owner")
+    private String getOwnerId() {
         return owner;
     }
     
@@ -83,6 +98,60 @@ public class MetaBlock {
         return item;
     }
     
+    public void setProperty(String key, Object value) {
+        metadata.put(key, value);
+    }
+    
+    public void removeProperty(String key) {
+        metadata.remove(key);
+    }
+    
+    public boolean hasProperty(String key) {
+        return metadata.containsKey(key);
+    }
+    
+    public Object getProperty(String key) {
+        return metadata.get(key);
+    }
+    
+    public int getIntProperty(String key) {
+        return tryParse(key, Integer::parseInt, 0);
+    }
+        
+    public float getFloatProperty(String key) {
+        return tryParse(key, Float::parseFloat, 0.0f);
+    }
+    
+    public boolean getBooleanProperty(String key) {
+        return Boolean.parseBoolean(String.valueOf(getProperty(key)));
+    }
+    
+    public String getStringProperty(String key) {
+        Object value = metadata.get(key);
+        return value != null && value instanceof String ? (String)value : null;
+    }
+    
+    /**
+     * Generic function for parsing a number from a string.
+     */
+    private <T> T tryParse(String key, Function<String, T> parseFunction, T def) {
+        Object value = metadata.get(key);
+        
+        if(value == null) {
+            return def;
+        }
+        
+        T result = def;
+        
+        try {
+            result = parseFunction.apply(String.valueOf(value));
+        } catch(NumberFormatException e) {
+            // Discard silently
+        }
+        
+        return result;
+    }
+    
     public void setMetadata(Map<String, Object> metadata) {
         this.metadata = metadata;
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/SteamIteration.java b/gameserver/src/main/java/brainwine/gameserver/zone/SteamIteration.java
new file mode 100644
index 0000000..6728043
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/SteamIteration.java
@@ -0,0 +1,35 @@
+package brainwine.gameserver.zone;
+
+/**
+ * Used by {@link SteamManager} to keep track of things.
+ */
+public class SteamIteration {
+    
+    private int x;
+    private int y;
+    private byte direction;
+    private short depth;
+    
+    public SteamIteration(int x, int y, int direction, int depth) {
+        this.x = x;
+        this.y = y;
+        this.direction = (byte)direction;
+        this.depth = (short)depth;
+    }
+    
+    public int getX() {
+        return x;
+    }
+    
+    public int getY() {
+        return y;
+    }
+    
+    public byte getDirection() {
+        return direction;
+    }
+    
+    public short getDepth() {
+        return depth;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java
new file mode 100644
index 0000000..cc86858
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java
@@ -0,0 +1,227 @@
+package brainwine.gameserver.zone;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+
+import brainwine.gameserver.item.Item;
+
+/**
+ * Distributes steam through collectors to nearby machines via pipes.
+ */
+public class SteamManager {
+    
+    public static final int STEAM_UPDATE_INTERVAL = 3000; // Update interval in milliseconds
+    public static final int MAX_ITERATIONS = 300; // Maximum number of iterations before giving up
+    public static final int MAX_COLLECTOR_DISTANCE = 350; // Collectors that are not within this distance of any players in the zone will be skipped
+    public static final byte STATE_EMPTY = 0x0; // Nothing or unrelated
+    public static final byte STATE_PIPE = 0x1; // Pipe
+    public static final byte STATE_COLLECTOR = 0x2; // Active collector
+    private final Set<Integer> collectorIndices = new HashSet<>();
+    private final Set<Integer> steamableIndices = new HashSet<>();
+    private final Set<Integer> processedIndices = new HashSet<>();
+    private final List<Integer> expiredSteamableIndices = new ArrayList<>();
+    private final Queue<SteamIteration> processQueue = new ArrayDeque<>();
+    private final Zone zone;
+    private byte[] data;
+    private long lastUpdateAt;
+    
+    public SteamManager(Zone zone) {
+        this.zone = zone;
+        this.data = new byte[(zone.getWidth() * zone.getHeight()) >> 2];
+    }
+    
+    public void tick(double deltaTime) {
+        long now = System.currentTimeMillis();
+        
+        // Check if it's time to update steam yet
+        if(now > lastUpdateAt + STEAM_UPDATE_INTERVAL) {
+            updateSteam();
+            lastUpdateAt = now;
+        }
+    }
+    
+    private void updateSteam() {
+        // Do nothing if there are no players in this zone
+        if(zone.getPlayerCount() == 0) {
+            return;
+        }
+        
+        // Clear data from previous run
+        processedIndices.clear();
+        expiredSteamableIndices.clear();
+        
+        // Turn off all steam-powered objects
+        for(int index : steamableIndices) {
+            int x = index % zone.getWidth();
+            int y = index / zone.getWidth();
+            
+            // Skip if chunk isn't loaded
+            if(!zone.isChunkLoaded(x, y)) {
+                expiredSteamableIndices.add(index);
+                continue;
+            }
+            
+            Item item = zone.getBlock(x, y).getFrontItem();
+            
+            // Skip if front item doesn't use steam
+            if(!item.usesSteam()) {
+                expiredSteamableIndices.add(index);
+                continue;
+            }
+            
+            // Update block
+            zone.updateBlock(x, y, item.getLayer(), item, 0);
+        }
+        
+        // Unindex expired steamables
+        for(int index : expiredSteamableIndices) {
+            steamableIndices.remove(index);
+        }
+        
+        // Enqueue blocks at the spouts of all collectors
+        for(int index : collectorIndices) {
+            int x = index % zone.getWidth();
+            int y = index / zone.getWidth();
+            
+            // Skip if no player is close to this collector
+            if(zone.getPlayersInRange(x, y, MAX_COLLECTOR_DISTANCE).isEmpty()) {
+                continue;
+            }
+            
+            // Queue spouts
+            processQueue.add(new SteamIteration(x + 1, y - 3, 0, 0)); // Top
+            processQueue.add(new SteamIteration(x + 3, y - 1, 1, 0)); // Right
+            processQueue.add(new SteamIteration(x + 1, y + 1, 2, 0)); // Bottom
+            processQueue.add(new SteamIteration(x - 1, y - 1, 3, 0)); // Left
+        }
+        
+        // Travel down the pipeline and power on any machines that are reached by it
+        while(!processQueue.isEmpty()) {
+            SteamIteration iteration = processQueue.poll();
+            int depth = iteration.getDepth();
+            
+            // Skip if depth limit has been reached
+            if(depth >= MAX_ITERATIONS) {
+                continue;
+            }
+            
+            int x = iteration.getX();
+            int y = iteration.getY();
+            
+            // Skip if coordinates are out of bounds
+            if(!zone.areCoordinatesInBounds(x, y)) {
+                continue;
+            }
+            
+            int index = zone.getBlockIndex(x, y);
+            
+            // Skip if block has already been processed
+            if(processedIndices.contains(index)) {
+                continue;
+            }
+            
+            processedIndices.add(index);
+            
+            // Skip if block is not a pipe
+            if(getState(x, y) != STATE_PIPE) {
+                
+                // ...but activate it first if it uses steam!
+                if(steamableIndices.contains(index)) {
+                    Item item = zone.getBlock(x, y).getFrontItem();
+                    zone.updateBlock(x, y, item.getLayer(), item, 1);
+                }
+                
+                continue;
+            }
+            
+            byte direction = iteration.getDirection();
+            int nextDepth = depth + 1;
+            
+            // Enqueue adjacent blocks for processing
+            if(direction != 2) processQueue.add(new SteamIteration(x, y - 1, 0, nextDepth)); // Top
+            if(direction != 3) processQueue.add(new SteamIteration(x + 1, y, 1, nextDepth)); // Right
+            if(direction != 0) processQueue.add(new SteamIteration(x, y + 1, 2, nextDepth)); // Bottom
+            if(direction != 1) processQueue.add(new SteamIteration(x - 1, y, 3, nextDepth)); // Left
+        }
+    }
+    
+    public void indexBlock(int x, int y, Item item) {
+        int index = zone.getBlockIndex(x, y);
+        
+        // Does it use steam?
+        if(!item.usesSteam()) {
+            steamableIndices.remove(index);
+            
+            // Is it a pipe?
+            if(!item.hasId("mechanical/pipe")) {
+
+                // Is it a collector and is it on top of a steam vent?
+                if(!item.hasId("mechanical/collector") || !isCollectorActive(x, y)) {
+                    collectorIndices.remove(index);
+                    setState(index, STATE_EMPTY);
+                    return;
+                }
+                
+                collectorIndices.add(index);
+                setState(index, STATE_COLLECTOR);
+                return;
+            }
+            
+            setState(index, STATE_PIPE);
+            return;
+        }
+        
+        steamableIndices.add(index);
+        setState(index, STATE_EMPTY);
+    }
+    
+    private boolean isCollectorActive(int x, int y) {
+        return zone.isChunkLoaded(x + 1, y - 1) && zone.getBlock(x + 1, y - 1).getBaseItem().hasId("base/vent");
+    }
+    
+    protected void setData(byte[] data) {
+        // Do nothing if data is null
+        if(data == null) {
+            return;
+        }
+        
+        int size = zone.getWidth() * zone.getHeight();
+        
+        // Do nothing if data size is incorrect
+        if(data.length << 2 != size) {
+            return;
+        }
+        
+        this.data = data;
+        
+        // Index active collectors
+        for(int i = 0; i < size; i++) {
+            if(getState(i) == STATE_COLLECTOR) {
+                collectorIndices.add(i);
+            }
+        }
+    }
+    
+    private void setState(int index, byte state) {
+        int byteOffset = index >> 2;
+        int bitOffset = (index % 4) << 1;
+        data[byteOffset] &= ~(0x3 << bitOffset); // Clear bits
+        data[byteOffset] |= (state & 0x3) << bitOffset; // Set bits
+    }
+    
+    private int getState(int x, int y) {
+        return getState(zone.getBlockIndex(x, y));
+    }
+    
+    private int getState(int index) {
+        return (data[index >> 2] >> ((index % 4) << 1)) & 0x3;
+    }
+    
+    protected byte[] getData() {
+        return data;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java
index 97cedf5..a23d040 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java
@@ -2,7 +2,6 @@ package brainwine.gameserver.zone;
 
 import java.io.File;
 import java.time.OffsetDateTime;
-import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -11,7 +10,6 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Queue;
 import java.util.Random;
 import java.util.Set;
 import java.util.UUID;
@@ -24,11 +22,15 @@ import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonValue;
 
 import brainwine.gameserver.GameServer;
+import brainwine.gameserver.Timer;
 import brainwine.gameserver.entity.Entity;
 import brainwine.gameserver.entity.npc.Npc;
+import brainwine.gameserver.entity.npc.NpcData;
 import brainwine.gameserver.entity.player.ChatType;
 import brainwine.gameserver.entity.player.NotificationType;
 import brainwine.gameserver.entity.player.Player;
+import brainwine.gameserver.item.DamageType;
+import brainwine.gameserver.item.Fieldability;
 import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.ItemRegistry;
 import brainwine.gameserver.item.ItemUseType;
@@ -41,6 +43,7 @@ import brainwine.gameserver.server.messages.BlockChangeMessage;
 import brainwine.gameserver.server.messages.BlockMetaMessage;
 import brainwine.gameserver.server.messages.ChatMessage;
 import brainwine.gameserver.server.messages.ConfigurationMessage;
+import brainwine.gameserver.server.messages.EffectMessage;
 import brainwine.gameserver.server.messages.LightMessage;
 import brainwine.gameserver.server.messages.ZoneExploredMessage;
 import brainwine.gameserver.server.messages.ZoneStatusMessage;
@@ -73,11 +76,12 @@ public class Zone {
     private float temperature;
     private float acidity;
     private final ChunkManager chunkManager;
+    private final SteamManager steamManager;
     private final WeatherManager weatherManager = new WeatherManager();
     private final EntityManager entityManager = new EntityManager(this);
     private final LiquidManager liquidManager = new LiquidManager(this);
-    private final Queue<DugBlock> digQueue = new ArrayDeque<>();
     private final List<BlockChangeData> blockChanges = new ArrayList<>();
+    private final List<Timer<Integer>> blockTimers = new ArrayList<>();
     private final Set<Integer> pendingSunlight = new HashSet<>();
     private final Map<String, Integer> dungeons = new HashMap<>();
     private final Map<Integer, MetaBlock> metaBlocks = new HashMap<>();
@@ -95,6 +99,7 @@ public class Zone {
         this.sunlight = sunlight != null && sunlight.length == width ? sunlight : this.sunlight;
         this.depths = depths != null && depths.length == 3 ? depths : this.depths;
         this.chunksExplored = chunksExplored != null && chunksExplored.length == getChunkCount() ? chunksExplored : this.chunksExplored;
+        steamManager.setData(data.getSteamData());
         pendingSunlight.addAll(data.getPendingSunlight());
         acidity = biome == Biome.ARCTIC || biome == Biome.SPACE ? 0 : config.getAcidity();
         creationDate = config.getCreationDate();
@@ -113,6 +118,7 @@ public class Zone {
         sunlight = new int[width];
         chunksExplored = new boolean[numChunksWidth * numChunksHeight];
         chunkManager = new ChunkManager(this);
+        steamManager = new SteamManager(this);
         acidity = biome == Biome.ARCTIC || biome == Biome.SPACE ? 0 : 1;
         Arrays.fill(surface, height);
         Arrays.fill(sunlight, height);
@@ -128,6 +134,7 @@ public class Zone {
         weatherManager.tick(deltaTime);
         entityManager.tick(deltaTime);
         liquidManager.tick(deltaTime);
+        steamManager.tick(deltaTime);
         
         // One full cycle = 1200 seconds = 20 minutes
         time += deltaTime * (1.0F / 1200.0F);
@@ -136,26 +143,22 @@ public class Zone {
             time -= 1.0F;
         }
         
+        // Send zone status update
         if(!getPlayers().isEmpty()) {
             if(now >= lastStatusUpdate + 4000) {
-                sendMessage(new ZoneStatusMessage(getStatusConfig()));
+                for(Player player : getPlayers()) {
+                    sendMessage(new ZoneStatusMessage(getStatusConfig(player)));
+                }
+                
                 lastStatusUpdate = now;
             }
         }
         
-        if(!digQueue.isEmpty()) {
-            DugBlock dugBlock = digQueue.peek();
-            
-            if(now >= dugBlock.getTime()) {
-                digQueue.poll();
-                int x = dugBlock.getX();
-                int y = dugBlock.getY();
-                Block block = getBlock(x, y);
-                
-                if(block != null && block.getFrontItem().hasId("ground/earth-dug")) {
-                    updateBlock(x, y, Layer.FRONT, dugBlock.getItem(), dugBlock.getMod());
-                }
-            }
+        // Process block timers
+        if(!blockTimers.isEmpty()) {
+            List<Timer<Integer>> readyTimers = blockTimers.stream().filter(timer -> now >= timer.getTime()).collect(Collectors.toList());
+            blockTimers.removeAll(readyTimers);
+            readyTimers.forEach(Timer::process);
         }
         
         // Send block changes to players who they are relevant to
@@ -191,7 +194,7 @@ public class Zone {
      * @param message The message to send.
      * @param chunk The chunk near which players must be.
      */
-    public void sendMessageToChunk(Message message, Chunk chunk) {
+    public void sendLocalMessage(Message message, Chunk chunk) {
         for(Player player : getPlayers()) {
             if(player.isChunkActive(chunk)) {
                 player.sendMessage(message);
@@ -199,6 +202,22 @@ public class Zone {
         }
     }
     
+    public void sendLocalMessage(Message message, float x, float y) {
+        sendLocalMessage(message, (int)x, (int)y);
+    }
+    
+    public void sendLocalMessage(Message message, int x, int y) {
+        if(!isChunkLoaded(x, y)) {
+            return;
+        }
+        
+        sendLocalMessage(message, getChunk(x, y));
+    }
+    
+    public void sendBlockMetaUpdate(MetaBlock metaBlock) {
+        sendLocalMessage(new BlockMetaMessage(metaBlock), metaBlock.getX(), metaBlock.getY());
+    }
+    
     public void sendChatMessage(Player sender, String text) {
         sendChatMessage(sender, text, ChatType.CHAT);
     }
@@ -215,6 +234,10 @@ public class Zone {
         GameServer.getInstance().notify(String.format("%s: %s", sender.getName(), text), NotificationType.CHAT);
     }
     
+    public void spawnEffect(float x, float y, String type, Object data) {
+        sendLocalMessage(new EffectMessage(x, y, type, data), x, y);
+    }
+    
     public boolean isPointVisibleFrom(int x1, int y1, int x2, int y2) {
         return raycast(x1, y1, x2, y2) == null;
     }
@@ -299,6 +322,173 @@ public class Zone {
         return all ? coords : null;
     }
     
+    public void explode(int x, int y, float radius, Entity cause, String effect) {
+        explode(x, y, radius, cause, false, 0, null, effect);
+    }
+    
+    public void explode(int x, int y, float radius, Entity cause, boolean destructive, float baseDamage, DamageType damageType, String effect) {
+        // Do nothing if the chunk at the target location isn't loaded
+        if(!isChunkLoaded(x, y)) {
+            return;
+        }
+        
+        spawnEffect(x + 0.5F, y + 0.5F, effect, radius);
+        Player player = cause instanceof Player ? (Player)cause : null;
+        Item item = getBlock(x, y).getFrontItem();
+        
+        // Try to destroy the block at the source of the explosion
+        if(item.getFieldability() == Fieldability.FALSE) {
+            updateBlock(x, y, Layer.FRONT, 0);
+            
+            if(destructive && !isBlockProtected(x, y, player)) {
+                updateBlock(x, y, Layer.BACK, 0);
+            }
+        }
+        
+        // Destroy blocks within range if the explosion is destructive
+        if(destructive) {
+            int rayCount = (int)Math.ceil(radius * 8);
+            List<List<Vector2i>> rays = new ArrayList<>();
+            List<Vector2i> affectedBlocks = new ArrayList<>();
+            Set<Integer> processed = new HashSet<>();
+            
+            // Determine the outer points of the blast circle and cast rays to them
+            for(int i = 0; i < rayCount; i++) {
+                float rayDistance = (float)(radius * (Math.random() * 0.4F + 0.8F));
+                float angle = (float)Math.toRadians(i * (360.0F / rayCount));
+                int targetX = (int)(x + rayDistance * Math.sin(angle));
+                int targetY = (int)(y + rayDistance * Math.cos(angle));
+                rays.add(raycast(x, y, targetX, targetY, true, true, false));
+            }
+            
+            // Fetch list of field blocks that are within range of the explosion (drastically speeds up the protection check)
+            Collection<MetaBlock> fieldBlocksInRange = fieldBlocks.values().stream()
+                    .filter(metaBlock -> MathUtils.inRange(x, y, metaBlock.getX(), metaBlock.getY(), metaBlock.getItem().getField() + radius * 2))
+                    .collect(Collectors.toList());
+            
+            // Determine which blocks to destroy by figuring out where each ray should stop
+            for(List<Vector2i> ray : rays) {
+                for(Vector2i position : ray) {
+                    int positionX = position.getX();
+                    int positionY = position.getY();
+                    int index = positionY * width + positionX;
+                    
+                    // Skip if block has been processed
+                    if(processed.contains(index)) {
+                        continue;
+                    }
+                                        
+                    // Skip if not in bounds
+                    if(!areCoordinatesInBounds(positionX, positionY)) {
+                        break;
+                    }
+                    
+                    Item frontItem = getBlock(positionX, positionY).getFrontItem();
+                    double distance = MathUtils.distance(x, y, positionX, positionY);
+                    double power = radius - distance;
+                    
+                    // Do not destroy block if it invulnerable or too tough
+                    if(!frontItem.isAir() && (frontItem.isInvulnerable() || frontItem.getToughness() >= power)) {
+                        break;
+                    }
+                    
+                    // Do not destroy block if it is protected
+                    if(isBlockProtected(positionX, positionY, player, fieldBlocksInRange) || frontItem.hasField()) {
+                        // Keep following this ray if the block isn't occupied
+                        if(!frontItem.isWhole()) {
+                            continue;
+                        }
+                        
+                        break;
+                    }
+                    
+                    // Metadata check
+                    MetaBlock metaBlock = getMetaBlock(positionX, positionY);
+                    
+                    if(metaBlock != null) {
+                        // Do not destroy block if it is a container with loot
+                        if(frontItem.hasUse(ItemUseType.CONTAINER) && metaBlock.hasProperty("$")) {
+                            continue;
+                        }
+                        
+                        // TODO dungeon switch check
+                    }
+                    
+                    affectedBlocks.add(position);
+                    processed.add(index);
+                }
+            }
+
+            // Sort affected blocks by their distance from the explosion center
+            affectedBlocks.sort((a, b) -> {
+                double distanceA = MathUtils.distance(x, y, a.getX(), a.getY());
+                double distanceB = MathUtils.distance(x, y, b.getX(), b.getY());
+                return distanceA > distanceB ? 1 : distanceB > distanceA ? -1 : 0;
+            });
+            
+            // Destroy affected blocks
+            for(Vector2i position : affectedBlocks) {
+                updateBlock(position.getX(), position.getY(), Layer.FRONT, 0);
+                updateBlock(position.getX(), position.getY(), Layer.BACK, 0);
+            }
+        }
+        
+        // Fetch list of nearby entities
+        List<Entity> nearbyEntities = getEntitiesInRange(x, y, radius);
+        
+        // Damage nearby entities based on their distance from the explosion
+        for(Entity entity : nearbyEntities) {
+            // Cast a ray from the explosion to the entity and damage it if it reaches it
+            if(entity.canSee(x, y)) {
+                double distance = MathUtils.distance(x, y, entity.getX(), entity.getY());
+                float damage = (float)(baseDamage - distance);
+                entity.attack(cause, item, damage, damageType);
+            }
+        }
+    }
+    
+    public void explodeLiquid(int x, int y, int range, int liquid) {
+        explodeLiquid(x, y, range, ItemRegistry.getItem(liquid));
+    }
+    
+    public void explodeLiquid(int x, int y, int range, String liquid) {
+        explodeLiquid(x, y, range, ItemRegistry.getItem(liquid));
+    }
+    
+    public void explodeLiquid(int x, int y, int range, Item liquid) {
+        // Do nothing if liquid isn't actually a liquid
+        if(liquid.getLayer() != Layer.LIQUID) {
+            return;
+        }
+        
+        // Place liquid blocks around the explosion
+        for(int i = x - range; i <= x + range; i++) {
+            for(int j = y - range; j <= y + range; j++) {
+                // Skip if not in range
+                if(!MathUtils.inRange(x, y, i, j, range)) {
+                    continue;
+                }
+                
+                // Place liquid if target block isn't solid
+                if(!isBlockSolid(i, j, true)) {
+                    updateBlock(i, j, Layer.LIQUID, liquid, 5);
+                }
+            }
+        }
+    }
+    
+    public boolean isBlockWhole(int x, int y) {
+        return areCoordinatesInBounds(x, y) && getBlock(x, y).getFrontItem().isWhole();
+    }
+    
+    public boolean isBlockEarthy(int x, int y) {
+        return areCoordinatesInBounds(x, y) && getBlock(x, y).getFrontItem().isEarthy();
+    }
+    
+    public boolean isBlockNatural(int x, int y) {
+        return areCoordinatesInBounds(x, y) && getBlock(x, y).isNatural();
+    }
+    
     public boolean isBlockSolid(int x, int y) {
         return isBlockSolid(x, y, true);
     }
@@ -356,14 +546,18 @@ public class Zone {
         return isBlockProtected(x, y, null);
     }
     
-    public boolean isBlockProtected(int x, int y, Player from) {
-        for(MetaBlock fieldBlock : fieldBlocks.values()) {
+    public boolean isBlockProtected(int x, int y, Player player) {
+        return isBlockProtected(x, y, player, fieldBlocks.values());
+    }
+    
+    public boolean isBlockProtected(int x, int y, Player player, Collection<MetaBlock> fieldBlocks) {
+        for(MetaBlock fieldBlock : fieldBlocks) {
             Item item = fieldBlock.getItem();
             int fX = fieldBlock.getX();
             int fY = fieldBlock.getY();
             int field = fieldBlock.getItem().getField();
             
-            if(from == null || !ownsMetaBlock(fieldBlock, from)) {
+            if(player == null || !fieldBlock.isOwnedBy(player)) {
                 if(item.isDish()) {
                     if(MathUtils.inRange(x, y, fX, fY, field)) {
                         return true;
@@ -385,7 +579,7 @@ public class Zone {
             int fY = fieldBlock.getY();
             int fField = fieldBlock.getItem().getField();
             
-            if(MathUtils.inRange(x, y, fX, fY, field + fField) && !ownsMetaBlock(fieldBlock, player)) {
+            if(MathUtils.inRange(x, y, fX, fY, field + fField) && !fieldBlock.isOwnedBy(player)) {
                 return true;
             }
         }
@@ -405,7 +599,7 @@ public class Zone {
             for(int j = 0; j < height; j++) {
                 int index = j * width + i;
                 Block block = getBlock(x + i, y + j);
-                blocks[index] = new Block(block.getBaseItem(), block.getBackItem(), block.getBackMod(), block.getFrontItem(), block.getFrontMod(), block.getLiquidItem(), block.getLiquidMod());
+                blocks[index] = new Block(block.getBaseItem(), block.getBackItem(), block.getBackMod(), block.getFrontItem(), block.getFrontMod(), block.getLiquidItem(), block.getLiquidMod(), 0);
                 MetaBlock metaBlock = metaBlocks.get(getBlockIndex(x + i, j + y));
                 
                 if(metaBlock != null) {
@@ -655,14 +849,44 @@ public class Zone {
         return dungeons.containsKey(id);
     }
     
-    public void digBlock(int x, int y) {
+    public boolean digBlock(int x, int y) {
         if(!areCoordinatesInBounds(x, y)) {
-            return;
+            return false;
         }
         
         Block block = getBlock(x, y);
-        digQueue.add(new DugBlock(x, y, block.getFrontItem(), block.getFrontMod(), System.currentTimeMillis() + 10000));
+        Item item = block.getFrontItem();
+        
+        if(!item.isDiggable()) {
+            return !item.isWhole();
+        }
+        
+        int mod = block.getFrontMod();
         updateBlock(x, y, Layer.FRONT, "ground/earth-dug");
+        addBlockTimer(x, y, 10000, () -> {
+            if(block.getFrontItem().hasId("ground/earth-dug")) {
+                updateBlock(x, y, Layer.FRONT, item, mod);
+            }
+        });
+        
+        return true;
+    }
+    
+    public void addBlockTimer(int x, int y, long delay, Runnable task) {
+        removeBlockTimer(x, y);
+        blockTimers.add(new Timer<>(getBlockIndex(x, y), delay, task));
+    }
+    
+    public void removeBlockTimer(int x, int y) {
+        blockTimers.removeIf(timer -> timer.getKey() == getBlockIndex(x, y));
+    }
+    
+    public void processBlockTimer(int x, int y) {
+        Timer<Integer> timer = blockTimers.stream().filter(t -> t.getKey() == getBlockIndex(x, y)).findFirst().orElse(null);
+        
+        if(timer != null) {
+            timer.process(true);
+        }
     }
     
     public void updateBlock(int x, int y, Layer layer, int item) {
@@ -715,7 +939,7 @@ public class Zone {
         }
         
         Chunk chunk = getChunk(x, y);        
-        chunk.getBlock(x, y).updateLayer(layer, item, mod);
+        chunk.getBlock(x, y).updateLayer(layer, item, mod, owner == null ? 0 : owner.getBlockHash()); // TODO owner hash should get updated on place only!!
         chunk.setModified(true);
         
         // Queue block update if there are players in this zone.
@@ -739,7 +963,9 @@ public class Zone {
                 removeMetaBlock(x, y);
             }
             
+            removeBlockTimer(x, y);
             entityManager.trySpawnBlockEntity(x, y);
+            steamManager.indexBlock(x, y, item);
             
             if(item.isWhole() && y < sunlight[x]) {
                 sunlight[x] = y;
@@ -747,7 +973,7 @@ public class Zone {
                 recalculateSunlight(x, sunlight[x]);
             }
             
-            sendMessageToChunk(new LightMessage(x, getSunlight(x, 1)), chunk);
+            sendLocalMessage(new LightMessage(x, getSunlight(x, 1)), chunk);
         } else if(layer == Layer.LIQUID) {
             if(!item.isAir() && mod > 0) {
                 liquidManager.indexLiquidBlock(x, y);
@@ -813,8 +1039,7 @@ public class Zone {
         
         switch(meta) {
             case LOCAL:
-                sendMessageToChunk(metaBlock == null ? new BlockMetaMessage(x, y) 
-                        : new BlockMetaMessage(metaBlock), getChunk(x, y));
+                sendLocalMessage(metaBlock == null ? new BlockMetaMessage(x, y) : new BlockMetaMessage(metaBlock), x, y);
                 break;
             case GLOBAL:
                 sendMessage(new BlockMetaMessage(x, y)); // Send empty one first or it won't work for some reason
@@ -860,14 +1085,6 @@ public class Zone {
         indexDungeons();
     }
     
-    private boolean ownsMetaBlock(MetaBlock metaBlock, Player player) {
-        if(!metaBlock.hasOwner()) {
-            return false;
-        }
-        
-        return player.getDocumentId().equals(metaBlock.getOwner());
-    }
-    
     public MetaBlock getMetaBlock(int x, int y) {
         return metaBlocks.get(getBlockIndex(x, y));
     }
@@ -927,6 +1144,18 @@ public class Zone {
         return entityManager.getPlayersInRange(x, y, range);
     }
     
+    public void spawnPersistentNpcs(Collection<NpcData> data) {
+        entityManager.spawnPersistentNpcs(data);
+    }
+    
+    public Npc spawnEntity(String type, int x, int y) {
+        return entityManager.spawnEntity(type, x, y);
+    }
+    
+    public Npc spawnEntity(String type, int x, int y, boolean effect) {
+        return entityManager.spawnEntity(type, x, y, effect);
+    }
+    
     public void spawnEntity(Entity entity, int x, int y) {
         entityManager.spawnEntity(entity, x, y);
     }
@@ -967,6 +1196,10 @@ public class Zone {
         return entityManager.getNpcs();
     }
     
+    public List<Npc> getPersistentNpcs() {
+        return entityManager.getPersistentNpcs();
+    }
+    
     public Player getPlayer(int entityId) {
         return entityManager.getPlayer(entityId);
     }
@@ -987,6 +1220,20 @@ public class Zone {
         return liquidManager.settleLiquids();
     }
     
+    /**
+     * @return The specified coordinates in a player-readable format
+     * For example, {@code x: 200 y: 300} in a plain biome becomes {@code 800 west, 100 below}
+     */
+    public String getReadableCoordinates(int x, int y) {
+        int center = width / 2;
+        int surface = biome == Biome.DEEP ? -1000 : 200;
+        String directionX = x < center ? "west" : x > center ? "east" : "central";
+        String directionY = y > surface ? "below" : "above";
+        String coordX = String.format("%s %s", Math.abs(x - center), directionX);
+        String coordY = String.format("%s %s", Math.abs(y - surface), directionY);
+        return String.format("%s, %s", coordX, coordY);
+    }
+    
     public boolean areCoordinatesInBounds(int x, int y) {
         return x >= 0 && y >= 0 && x < width && y < height;
     }
@@ -999,16 +1246,18 @@ public class Zone {
             // Update pending sunlight
             if(pendingSunlight.contains(x)) {
                 recalculateSunlight(x, sunlight[x]);
-                sendMessageToChunk(new LightMessage(x, getSunlight(x, 1)), chunk);
+                sendLocalMessage(new LightMessage(x, getSunlight(x, 1)), chunk);
             }
             
             for(int y = chunkY; y < chunkY + chunk.getHeight(); y++) {
                 // Spawn block-related entities
                 entityManager.trySpawnBlockEntity(x, y);
-                
-                // Index liquids
                 Block block = chunk.getBlock(x, y);
                 
+                // Index steam blocks
+                steamManager.indexBlock(x, y, block.getFrontItem());
+                
+                // Index liquids
                 if(!block.getLiquidItem().isAir() && block.getLiquidMod() > 0) {
                     liquidManager.indexLiquidBlock(x, y);
                 }
@@ -1072,6 +1321,10 @@ public class Zone {
         return weatherManager;
     }
     
+    public boolean isUnderground(int x, int y) {
+        return areCoordinatesInBounds(x, y) && y >= surface[x];
+    }
+    
     public void setSurface(int x, int surface) {
         if(areCoordinatesInBounds(x, surface)) {
             this.surface[x] = surface;
@@ -1165,6 +1418,14 @@ public class Zone {
         return chunksExplored[chunkIndex] = true;
     }
     
+    public boolean isAreaExplored(int x, int y) {
+        return areCoordinatesInBounds(x, y) && chunksExplored[getChunkIndex(x, y)];
+    }
+    
+    protected byte[] getSteamData() {
+        return steamManager.getData();
+    }
+    
     public File getDirectory() {
     	return new File("zones", documentId);
     }
@@ -1297,16 +1558,16 @@ public class Zone {
     /**
      * @return A {@link Map} containing all the data necessary for use in {@link ZoneStatusMessage}.
      */
-    public Map<String, Object> getStatusConfig() {
-        Map<String, Object> config = new HashMap<>();
-        config.put("w", new int[] {
-                (int)(time * 10000), 
-                (int)(temperature * 10000), 
-                (int)(weatherManager.getPrecipitation() * 10000), 
-                (int)(weatherManager.getPrecipitation() * 10000), 
-                (int)(weatherManager.getPrecipitation() * 10000), 
-                (int)(acidity * 10000)
-        });
-        return config;
+    public Object getStatusConfig(Player player) {
+        int[] status = {
+            (int)(time * 10000), 
+            (int)(temperature * 10000), 
+            (int)(weatherManager.getPrecipitation() * 10000), 
+            (int)(weatherManager.getPrecipitation() * 10000), 
+            (int)(weatherManager.getPrecipitation() * 10000), 
+            (int)(acidity * 10000)
+        };
+        
+        return player.hasClientVersion("2.1.0") ? MapHelper.map("w", status) : status;
     }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneDataFile.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneDataFile.java
index 6eeaf4a..1e5fc97 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneDataFile.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneDataFile.java
@@ -26,16 +26,20 @@ public class ZoneDataFile {
     @JsonSetter(nulls = Nulls.AS_EMPTY)
     private boolean[] chunksExplored = {};
     
+    @JsonSetter(nulls = Nulls.AS_EMPTY)
+    private byte[] steamData = {};
+    
     public ZoneDataFile(Zone zone) {
-        this(zone.getSurface(), zone.getSunlight(), zone.getDepths(), zone.getPendingSunlight(), zone.getChunksExplored());
+        this(zone.getSurface(), zone.getSunlight(), zone.getDepths(), zone.getPendingSunlight(), zone.getChunksExplored(), zone.getSteamData());
     }
     
-    public ZoneDataFile(int[] surface, int[] sunlight, int[] depths, Collection<Integer> pendingSunlight, boolean[] chunksExplored) {
+    public ZoneDataFile(int[] surface, int[] sunlight, int[] depths, Collection<Integer> pendingSunlight, boolean[] chunksExplored, byte[] steamData) {
         this.surface = surface;
         this.sunlight = sunlight;
         this.depths = depths;
         this.pendingSunlight = pendingSunlight;
         this.chunksExplored = chunksExplored;
+        this.steamData = steamData;
     }
     
     @JsonCreator
@@ -60,4 +64,8 @@ public class ZoneDataFile {
     public boolean[] getChunksExplored() {
         return chunksExplored;
     }
+    
+    public byte[] getSteamData() {
+        return steamData;
+    }
 }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java
index 9c8e1eb..f8510c5 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java
@@ -13,6 +13,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Predicate;
+import java.util.stream.Collectors;
 import java.util.zip.DataFormatException;
 
 import org.apache.logging.log4j.LogManager;
@@ -24,6 +25,7 @@ import org.msgpack.jackson.dataformat.MessagePackFactory;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.PropertyNamingStrategies;
 
+import brainwine.gameserver.entity.npc.NpcData;
 import brainwine.gameserver.util.ZipUtils;
 import brainwine.gameserver.zone.gen.ZoneGenerator;
 import brainwine.shared.JsonHelper;
@@ -63,7 +65,7 @@ public class ZoneManager {
             generator = ZoneGenerator.getDefaultZoneGenerator();
         }
         
-        Zone zone = generator.generateZone(Biome.PLAIN, 2000, 600);
+        Zone zone = generator.generateZone(Biome.PLAIN);
         addZone(zone);
     }
     
@@ -84,6 +86,9 @@ public class ZoneManager {
         String id = file.getName();
         File dataFile = new File(file, "zone.dat");
         File legacyDataFile = new File(file, "shape.cmp");
+        File configFile = new File(file, "config.json");
+        File metaBlocksFile = new File(file, "metablocks.json");
+        File charactersFile = new File(file, "characters.json");
         
         try {
             ZoneDataFile data = null;
@@ -95,9 +100,19 @@ public class ZoneManager {
                 data = mapper.readValue(ZipUtils.inflateBytes(Files.readAllBytes(dataFile.toPath())), ZoneDataFile.class);
             }
             
-            ZoneConfigFile config = JsonHelper.readValue(new File(file, "config.json"), ZoneConfigFile.class);
+            ZoneConfigFile config = JsonHelper.readValue(configFile, ZoneConfigFile.class);
             Zone zone = new Zone(id, config, data);
-            zone.setMetaBlocks(JsonHelper.readList(new File(file, "metablocks.json"), MetaBlock.class));
+            
+            // Load meta blocks
+            if(metaBlocksFile.exists()) {
+                zone.setMetaBlocks(JsonHelper.readList(metaBlocksFile, MetaBlock.class));
+            }
+            
+            // Load characters
+            if(charactersFile.exists()) {
+                zone.spawnPersistentNpcs(JsonHelper.readList(charactersFile, NpcData.class));
+            }
+            
             addZone(zone);
         } catch (Exception e) {
             logger.error(SERVER_MARKER, "Zone load failure. id: {}", id, e);
@@ -131,7 +146,7 @@ public class ZoneManager {
             chunksExplored[i] = unpacker.unpackBoolean();
         }
         
-        ZoneDataFile data = new ZoneDataFile(surface, sunlight, null, pendingSunlight, chunksExplored);
+        ZoneDataFile data = new ZoneDataFile(surface, sunlight, null, pendingSunlight, chunksExplored, null);
         Files.write(outputFile.toPath(), ZipUtils.deflateBytes(mapper.writeValueAsBytes(data)));
         return data;
     }
@@ -147,10 +162,18 @@ public class ZoneManager {
         file.mkdirs();
         
         try {
+            // Serialize everything before writing to disk to minimize risk of data corruption if something goes wrong
+            byte[] charactersBytes = JsonHelper.writeValueAsBytes(zone.getPersistentNpcs().stream().map(NpcData::new).collect(Collectors.toList()));
+            byte[] metaBlocksBytes = JsonHelper.writeValueAsBytes(zone.getMetaBlocks());
+            byte[] configBytes = JsonHelper.writeValueAsBytes(new ZoneConfigFile(zone));
+            byte[] dataBytes = ZipUtils.deflateBytes(mapper.writeValueAsBytes(new ZoneDataFile(zone)));
+            
+            // Write data to files
             zone.saveChunks();
-            JsonHelper.writeValue(new File(file, "metablocks.json"), zone.getMetaBlocks());
-            JsonHelper.writeValue(new File(file, "config.json"), new ZoneConfigFile(zone));
-            Files.write(new File(file, "zone.dat").toPath(), ZipUtils.deflateBytes(mapper.writeValueAsBytes(new ZoneDataFile(zone))));
+            Files.write(new File(file, "characters.json").toPath(), charactersBytes);
+            Files.write(new File(file, "metablocks.json").toPath(), metaBlocksBytes);
+            Files.write(new File(file, "config.json").toPath(), configBytes);
+            Files.write(new File(file, "zone.dat").toPath(), dataBytes);
         } catch(Exception e) {
             logger.error(SERVER_MARKER, "Zone save failure. id: {}", zone.getDocumentId(), e);
         }
@@ -178,8 +201,11 @@ public class ZoneManager {
     }
     
     public Zone getRandomZone() {
-        List<Zone> zones = new ArrayList<>();
-        zones.addAll(getZones());
+        return getRandomZone(null);
+    }
+    
+    public Zone getRandomZone(Predicate<Zone> predicate) {
+        List<Zone> zones = searchZones(predicate);
         return zones.get((int)(Math.random() * zones.size()));
     }
     
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java
index eafb3f1..9fbbd19 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java
@@ -15,6 +15,7 @@ import brainwine.gameserver.util.WeightedMap;
 import brainwine.gameserver.zone.gen.caves.CaveDecorator;
 import brainwine.gameserver.zone.gen.caves.CaveType;
 import brainwine.gameserver.zone.gen.models.Deposit;
+import brainwine.gameserver.zone.gen.models.LayerSeparator;
 import brainwine.gameserver.zone.gen.models.OreDeposit;
 import brainwine.gameserver.zone.gen.models.SpecialStructure;
 import brainwine.gameserver.zone.gen.models.StoneType;
@@ -33,6 +34,7 @@ public class GeneratorConfig {
     private double dungeonChance = 0.25;
     private double backgroundAccentChance = 0.033;
     private double backgroundDrawingChance = 0.001;
+    private LayerSeparator layerSeparator;
     private WeightedMap<StoneType> stoneTypes = new WeightedMap<>();
     private WeightedMap<Prefab> spawnBuildings = new WeightedMap<>();
     private WeightedMap<Prefab> dungeons = new WeightedMap<>();
@@ -86,6 +88,10 @@ public class GeneratorConfig {
         return backgroundDrawingChance;
     }
     
+    public LayerSeparator getLayerSeparator() {
+        return layerSeparator;
+    }
+    
     @JsonSetter(value = "stone_types", nulls = Nulls.SKIP)
     public WeightedMap<StoneType> getStoneTypes() {
         return stoneTypes;
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/ZoneGenerator.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/ZoneGenerator.java
index 0daaa6d..77a2573 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/ZoneGenerator.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/ZoneGenerator.java
@@ -13,6 +13,7 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import brainwine.gameserver.GameServer;
+import brainwine.gameserver.Naming;
 import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.util.ResourceUtils;
 import brainwine.gameserver.zone.Biome;
@@ -26,34 +27,6 @@ import brainwine.shared.JsonHelper;
 
 public class ZoneGenerator {
     
-    // TODO Collect more names and create a name generator that's actually proper lmao
-    private static final String[] FIRST_NAMES = {
-        "Malvern", "Tralee", "Horncastle", "Old", "Westwood",
-        "Citta", "Tadley", "Mossley", "West", "East",
-        "North", "South", "Wadpen", "Githam", "Soatnust",
-        "Highworth", "Creakynip", "Upper", "Lower", "Cannock",
-        "Dovercourt", "Limerick", "Pickering", "Glumshed", "Crusthack",
-        "Osyltyr", "Aberstaple", "New", "Stroud", "Crumclum",
-        "Crumsidle", "Bankswund", "Fiddletrast", "Bournpan", "St.",
-        "Funderbost", "Bexwoddly", "Pilkingheld", "Wittlepen", "Rabbitbleaker",
-        "Griffingumby", "Guilthead", "Bigglelund", "Bunnymold", "Rosesidle",
-        "Crushthorn", "Tanlyward", "Ahncrace", "Pilkingking", "Dingstrath",
-        "Axebury", "Ginglingtap", "Ballybibby", "Shadehoven"
-    };
-    
-    private static final String[] LAST_NAMES = {
-        "Falls", "Alloa", "Glen", "Way", "Dolente",
-        "Peak", "Heights", "Creek", "Banffshire", "Chagford",
-        "Gorge", "Valley", "Catacombs", "Depths", "Mines",
-        "Crickbridge", "Guildbost", "Pits", "Vaults", "Ruins",
-        "Dell", "Keep", "Chatterdin", "Scrimmance", "Gitwick",
-        "Ridge", "Alresford", "Place", "Bridge", "Glade",
-        "Mill", "Court", "Dooftory", "Hills", "Specklewint",
-        "Grove", "Aylesbury", "Wagwouth", "Russetcumby", "Point",
-        "Canyon", "Cranwarry", "Bluff", "Passage", "Crantippy",
-        "Kerbodome", "Dale", "Cemetery"
-    };
-    
     private static final Logger logger = LogManager.getLogger();
     private static final Map<String, ZoneGenerator> generators = new HashMap<>();
     private static final ZoneGenerator defaultGenerator = new ZoneGenerator();
@@ -149,7 +122,7 @@ public class ZoneGenerator {
     }
     
     public Zone generateZone(Biome biome) {
-        return generateZone(biome, 2000, 600);
+        return generateZone(biome, biome == Biome.DEEP ? 1200 : 2000, biome == Biome.DEEP ? 1000 : 600);
     }
     
     public Zone generateZone(Biome biome, int width, int height) {
@@ -158,7 +131,7 @@ public class ZoneGenerator {
     
     public Zone generateZone(Biome biome, int width, int height, int seed) {
         String id = generateDocumentId(seed);
-        String name = getRandomName();
+        String name = Naming.getRandomZoneName();
         int retryCount = 0;
         
         while(GameServer.getInstance().getZoneManager().getZoneByName(name) != null) {
@@ -168,7 +141,7 @@ public class ZoneGenerator {
                 break;
             }
             
-            name = getRandomName();
+            name = Naming.getRandomZoneName();
             retryCount++;
         }
         
@@ -192,7 +165,7 @@ public class ZoneGenerator {
     }
     
     public void generateZoneAsync(Biome biome, Consumer<Zone> callback) {
-        generateZoneAsync(biome, 2000, 600, callback);
+        generateZoneAsync(biome, biome == Biome.DEEP ? 1200 : 2000, biome == Biome.DEEP ? 1000 : 600, callback);
     }
     
     public void generateZoneAsync(Biome biome, int width, int height, Consumer<Zone> callback) {
@@ -210,12 +183,6 @@ public class ZoneGenerator {
         return new UUID(mostSigBits, leastSigBits).toString();
     }
     
-    private static String getRandomName() {
-        String firstName = FIRST_NAMES[(int)(Math.random() * FIRST_NAMES.length)];
-        String lastName = LAST_NAMES[(int)(Math.random() * LAST_NAMES.length)];
-        return firstName + " " + lastName;
-    }
-    
     private static int getRandomSeed() {
         return (int)(Math.random() * Integer.MAX_VALUE);
     }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/models/LayerSeparator.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/models/LayerSeparator.java
new file mode 100644
index 0000000..8bc144a
--- /dev/null
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/models/LayerSeparator.java
@@ -0,0 +1,51 @@
+package brainwine.gameserver.zone.gen.models;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import brainwine.gameserver.item.Item;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class LayerSeparator {
+    
+    @JsonProperty("item")
+    private Item item;
+    
+    @JsonProperty("min_thickness")
+    private int minThickness = 3;
+    
+    @JsonProperty("max_thickness")
+    private int maxThickness = 6;
+    
+    @JsonProperty("min_amplitude")
+    private double minAmplitude = 20;
+    
+    @JsonProperty("max_amplitude")
+    private double maxAimplitude = 20;
+    
+    @JsonCreator
+    private LayerSeparator(@JsonProperty(value = "item", required = true) Item item) {
+        this.item = item;
+    }
+    
+    public Item getItem() {
+        return item;
+    }
+    
+    public int getMinThickness() {
+        return minThickness;
+    }
+    
+    public int getMaxThickness() {
+        return maxThickness;
+    }
+    
+    public double getMinAmplitude() {
+        return minAmplitude;
+    }
+    
+    public double getMaxAmplitude() {
+        return maxAimplitude;
+    }
+}
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/CaveGeneratorTask.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/CaveGeneratorTask.java
index 40f5868..3921ee7 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/CaveGeneratorTask.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/CaveGeneratorTask.java
@@ -99,11 +99,11 @@ public class CaveGeneratorTask implements GeneratorTask {
                 // Generate a cave wall with a thickness depending on the size of the cave
                 if(asteroids || stoneType != StoneType.DEFAULT) {
                     ctx.updateBlock(x, y, Layer.BASE, stoneType.getBaseItem());
-                    int checkDistance = asteroids? 5 : 3;
+                    int checkDistance = asteroids ? 5 : 3;
                     
                     for(int i = x - checkDistance; i <= x + checkDistance; i++) {
                         for(int j = y - checkDistance; j <= y + checkDistance; j++) {
-                            if(ctx.inBounds(i, j) && !cells[i][j]) {
+                            if((asteroids ? ctx.isAir(i, j, Layer.FRONT) : ctx.isEarthy(i, j)) && !cells[i][j]) {
                                 double maxDistance = asteroids ? 4.5 + ctx.nextDouble() - 1 :
                                         MathUtils.clamp(cave.getSize() / 16.0, 1.8, checkDistance) + (ctx.nextDouble() - 0.5);
                                 double distance = Math.hypot(i - x, j - y);
@@ -169,7 +169,8 @@ public class CaveGeneratorTask implements GeneratorTask {
         
         for(int x = 0; x < width; x++) {
             for(int y = 0; y < height; y++) {
-                if((y >= ctx.getSurface(x) + ctx.nextInt(3)) && ctx.nextDouble() <= cellRate) {
+                if((terrainType == TerrainType.ASTEROIDS ? ctx.isAir(x, y, Layer.FRONT) : ctx.isEarthy(x, y)) 
+                        && (y >= ctx.getSurface(x) + ctx.nextInt(3)) && ctx.nextDouble() <= cellRate) {
                     cells[x][y] = true;
                 }
             }
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java
index f484ffe..c4aa59c 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java
@@ -152,7 +152,8 @@ public class StructureGeneratorTask implements GeneratorTask {
         Prefab spawnBuilding = spawnBuildings.next(ctx.getRandom());
         
         if(filled) {
-            int y = ctx.getHeight() / 8 + ctx.nextInt(Math.max(1, ctx.nextInt(ctx.getHeight() / 8)));
+            int min = ctx.getHeight() / 32;
+            int y = min + ctx.nextInt(Math.max(1, ctx.nextInt(min)));
             ctx.placePrefab(spawnBuilding, x, y);
         } else {
             ctx.placePrefabSurface(spawnBuilding, x);
diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/TerrainGeneratorTask.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/TerrainGeneratorTask.java
index 26783f8..281dc86 100644
--- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/TerrainGeneratorTask.java
+++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/TerrainGeneratorTask.java
@@ -1,10 +1,12 @@
 package brainwine.gameserver.zone.gen.tasks;
 
+import brainwine.gameserver.item.Item;
 import brainwine.gameserver.item.Layer;
 import brainwine.gameserver.util.SimplexNoise;
 import brainwine.gameserver.util.WeightedMap;
 import brainwine.gameserver.zone.gen.GeneratorConfig;
 import brainwine.gameserver.zone.gen.GeneratorContext;
+import brainwine.gameserver.zone.gen.models.LayerSeparator;
 import brainwine.gameserver.zone.gen.models.TerrainType;
 import brainwine.gameserver.zone.gen.surface.SurfaceRegion;
 import brainwine.gameserver.zone.gen.surface.SurfaceRegionType;
@@ -14,6 +16,7 @@ public class TerrainGeneratorTask implements GeneratorTask {
     private final TerrainType type;
     private final double minAmplitude;
     private final double maxAmplitude;
+    private final LayerSeparator layerSeparator;
     private final int surfaceRegionSize;
     private final WeightedMap<SurfaceRegionType> surfaceRegionTypes;
     
@@ -21,6 +24,7 @@ public class TerrainGeneratorTask implements GeneratorTask {
         type = config.getTerrainType();
         minAmplitude = config.getMinAmplitude();
         maxAmplitude = config.getMaxAmplitude();
+        layerSeparator = config.getLayerSeparator();
         surfaceRegionSize = config.getSurfaceRegionSize();
         surfaceRegionTypes = config.getSurfaceRegionTypes();
     }
@@ -29,7 +33,7 @@ public class TerrainGeneratorTask implements GeneratorTask {
     public void generate(GeneratorContext ctx) {
         int width = ctx.getWidth();
         int height = ctx.getHeight();
-        int surfaceLevel = height < 600 ? height / 3 : 200;
+        int surfaceLevel = type == TerrainType.ASTEROIDS ? (height < 600 ? height / 6 : 100) : (height < 600 ? height / 3 : 200);
         int lowestSurfaceLevel = 0;
         
         // Determine surface first, then start placing blocks.
@@ -72,5 +76,24 @@ public class TerrainGeneratorTask implements GeneratorTask {
                 ctx.updateBlock(x, y, Layer.BASE, "base/earth");
             }
         }
+        
+        // Generate layer separators
+        if(layerSeparator != null) {
+            Item item = layerSeparator.getItem();
+            int minThickness = layerSeparator.getMinThickness();
+            int maxThickness = layerSeparator.getMaxThickness();
+            double amplitude = ctx.nextDouble() * (layerSeparator.getMaxAmplitude() - layerSeparator.getMinAmplitude()) + layerSeparator.getMinAmplitude();
+            
+            for(int depth : ctx.getZone().getDepths()) {            
+                for(int x = 0; x < width; x++) {
+                    int start = (int)(SimplexNoise.noise2(ctx.getSeed(), x / 256.0, 0, 7) * amplitude) + depth - maxThickness / 2;
+                    int size = ctx.nextInt(maxThickness - minThickness) + minThickness;
+                    
+                    for(int y = start; y < start + size; y++) {
+                        ctx.updateBlock(x, y, item.getLayer(), item);
+                    }
+                }
+            }
+        }
     }
 }
diff --git a/gameserver/src/main/resources/defaults/generators/deep.json b/gameserver/src/main/resources/defaults/generators/deep.json
index a218fd0..f23768f 100644
--- a/gameserver/src/main/resources/defaults/generators/deep.json
+++ b/gameserver/src/main/resources/defaults/generators/deep.json
@@ -4,6 +4,13 @@
   "dungeon_chance": 0.4,
   "background_accent_chance": 0.033,
   "background_drawing_chance": 0.001,
+  "layer_separator": {
+    "item": "ground/blackrock",
+    "min_thickness": 3,
+    "max_thickness": 6,
+    "min_amplitude": 20,
+    "max_amplitude": 20
+  },
   "stone_types": {
     "default": 17,
     "limestone": 4
diff --git a/gameserver/src/main/resources/defaults/generators/hell.json b/gameserver/src/main/resources/defaults/generators/hell.json
index c577dc7..67c5ddf 100644
--- a/gameserver/src/main/resources/defaults/generators/hell.json
+++ b/gameserver/src/main/resources/defaults/generators/hell.json
@@ -7,6 +7,13 @@
   "dungeon_chance": 0.375,
   "background_accent_chance": 0.033,
   "background_drawing_chance": 0.001,
+  "layer_separator": {
+    "item": "ground/blackrock",
+    "min_thickness": 3,
+    "max_thickness": 6,
+    "min_amplitude": 20,
+    "max_amplitude": 20
+  },
   "stone_types": {
     "default": 1
   },
diff --git a/settings.gradle b/settings.gradle
index 38583d4..566f3e2 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,6 @@
 rootProject.name = 'brainwine'
+includeBuild 'build-logic'
 include('api', 'gameserver', 'shared')
+project(":api").name = 'brainwine-api'
+project(':gameserver').name = 'brainwine-gameserver'
+project(':shared').name = 'brainwine-shared'
diff --git a/shared/build.gradle b/shared/build.gradle
index 451764c..0782595 100644
--- a/shared/build.gradle
+++ b/shared/build.gradle
@@ -15,7 +15,3 @@ dependencies {
     api 'commons-validator:commons-validator:1.7'
     api 'org.apache.commons:commons-text:1.9'
 }
-
-jar {
-    archiveBaseName = 'brainwine-shared'
-}
diff --git a/src/main/java/brainwine/DirectDataFetcher.java b/src/main/java/brainwine/DirectDataFetcher.java
index 76a8bdb..ad6fdaa 100644
--- a/src/main/java/brainwine/DirectDataFetcher.java
+++ b/src/main/java/brainwine/DirectDataFetcher.java
@@ -6,6 +6,7 @@ import java.util.List;
 
 import brainwine.api.DataFetcher;
 import brainwine.api.models.ZoneInfo;
+import brainwine.gameserver.entity.player.Player;
 import brainwine.gameserver.entity.player.PlayerManager;
 import brainwine.gameserver.zone.Zone;
 import brainwine.gameserver.zone.ZoneManager;
@@ -34,6 +35,12 @@ public class DirectDataFetcher implements DataFetcher {
     public String login(String name, String password) {
         return playerManager.login(name, password);
     }
+    
+    @Override
+    public String fetchPlayerName(String name) {
+        Player player = playerManager.getPlayer(name);
+        return player == null ? null : player.getName();
+    }
 
     @Override
     public boolean verifyAuthToken(String name, String token) {
diff --git a/src/main/java/brainwine/Bootstrap.java b/src/main/java/brainwine/Main.java
similarity index 98%
rename from src/main/java/brainwine/Bootstrap.java
rename to src/main/java/brainwine/Main.java
index bb1969f..9f09f3f 100644
--- a/src/main/java/brainwine/Bootstrap.java
+++ b/src/main/java/brainwine/Main.java
@@ -21,7 +21,7 @@ import brainwine.gui.MainView;
 import brainwine.gui.theme.ThemeManager;
 import brainwine.util.SwingUtils;
 
-public class Bootstrap {
+public class Main {
 
     private static Logger logger = LogManager.getLogger();
     private static boolean disableGui = false;
@@ -41,10 +41,10 @@ public class Bootstrap {
             }
         }
         
-        new Bootstrap();
+        new Main();
     }
     
-    public Bootstrap() {
+    public Main() {
         // Create gui or directly start server if gui is disabled or not supported
         if(!disableGui && (Desktop.isDesktopSupported() || forceGui)) {
             try {
diff --git a/src/main/java/brainwine/ServerThread.java b/src/main/java/brainwine/ServerThread.java
index 8ca022f..47994ed 100644
--- a/src/main/java/brainwine/ServerThread.java
+++ b/src/main/java/brainwine/ServerThread.java
@@ -8,19 +8,19 @@ import org.apache.logging.log4j.Logger;
 import brainwine.api.Api;
 import brainwine.gameserver.GameServer;
 import brainwine.gameserver.TickLoop;
-import brainwine.gameserver.command.CommandManager;
+import brainwine.gameserver.commands.CommandManager;
 
 public class ServerThread extends Thread {
     
     private static Logger logger = LogManager.getLogger();
-    private final Bootstrap bootstrap;
+    private final Main main;
     private GameServer gameServer;
     private Api api;
     private boolean running;
     
-    public ServerThread(Bootstrap bootstrap) {
+    public ServerThread(Main main) {
         super("server");
-        this.bootstrap = bootstrap;
+        this.main = main;
     }
     
     @Override
@@ -36,7 +36,7 @@ public class ServerThread extends Thread {
             
             logger.info(SERVER_MARKER, "Server has started");
             running = true;
-            bootstrap.onServerStarted();
+            main.onServerStarted();
             
             while(!gameServer.shouldStop()) {
                 tickLoop.update();
@@ -80,7 +80,7 @@ public class ServerThread extends Thread {
             logger.error(SERVER_MARKER, "An unexpected exception occured whilst shutting down", e);
         } finally {
             running = false;
-            bootstrap.onServerStopped();
+            main.onServerStopped();
         }
     }
     
diff --git a/src/main/java/brainwine/gui/MainView.java b/src/main/java/brainwine/gui/MainView.java
index 8589902..13ac904 100644
--- a/src/main/java/brainwine/gui/MainView.java
+++ b/src/main/java/brainwine/gui/MainView.java
@@ -25,10 +25,11 @@ import com.formdev.flatlaf.extras.FlatAnimatedLafChange;
 import com.formdev.flatlaf.extras.components.FlatTabbedPane;
 import com.formdev.flatlaf.extras.components.FlatTabbedPane.TabAlignment;
 
-import brainwine.Bootstrap;
+import brainwine.Main;
 import brainwine.util.DesktopUtils;
 import brainwine.util.OperatingSystem;
 import brainwine.util.ProcessResult;
+import brainwine.util.ProcessUtils;
 import brainwine.util.RegistryKey;
 import brainwine.util.RegistryUtils;
 import brainwine.util.SwingUtils;
@@ -42,7 +43,7 @@ public class MainView {
     private final ServerPanel serverPanel;
     private final SettingsPanel settingsPanel;
     
-    public MainView(Bootstrap bootstrap) {
+    public MainView(Main main) {
         logger.info(GUI_MARKER, "Creating main view ...");
         
         // Panel
@@ -58,18 +59,14 @@ public class MainView {
             tabbedPane.addTab("Play Game", UIManager.getIcon("Brainwine.playIcon"), new GamePanel(this));
         }
         
-        tabbedPane.addTab("Server", UIManager.getIcon("Brainwine.serverIcon"), serverPanel = new ServerPanel(bootstrap));
+        tabbedPane.addTab("Server", UIManager.getIcon("Brainwine.serverIcon"), serverPanel = new ServerPanel(main));
         tabbedPane.addTab("Settings", UIManager.getIcon("Brainwine.settingsIcon"), settingsPanel = new SettingsPanel(this));
         panel.add(tabbedPane);
         
         // Menu
         JMenuBar menuBar = new JMenuBar();
         JMenu helpMenu = new JMenu("Help");
-        
-        if(OperatingSystem.isWindows()) {
-            helpMenu.add(SwingUtils.createAction("Clear Account Lock", this::showAccountLockPrompt));
-        }
-        
+        helpMenu.add(SwingUtils.createAction("Clear Account Lock", this::showAccountLockPrompt));
         helpMenu.add(SwingUtils.createAction("GitHub", () -> DesktopUtils.browseUrl(GITHUB_REPOSITORY_URL)));
         menuBar.add(helpMenu);
                 
@@ -82,7 +79,7 @@ public class MainView {
         frame.addWindowListener(new WindowAdapter() {
             @Override
             public void windowClosing(WindowEvent event) {
-                bootstrap.closeApplication();
+                main.closeApplication();
             }
         });
         frame.setJMenuBar(menuBar);
@@ -135,12 +132,16 @@ public class MainView {
             if(clearAccountLock()) {
                 JOptionPane.showMessageDialog(frame, "Account lock removed. Register your account next time.");
             } else {
-                JOptionPane.showMessageDialog(frame, "Failed to remove account lock.", "Error", JOptionPane.ERROR_MESSAGE);
+                JOptionPane.showMessageDialog(frame, "Could not remove account lock.\nEither there is no account lock, or an error has occured.", "Error", JOptionPane.ERROR_MESSAGE);
             }
         }
     }
     
     private boolean clearAccountLock() {
+        return OperatingSystem.isWindows() ? clearAccountLockWindows() : OperatingSystem.isMacOS() ? clearAccountLockMacOS() : false;
+    }
+    
+    private boolean clearAccountLockWindows() {
         ProcessResult queryResult = RegistryUtils.query(DEEPWORLD_PLAYERPREFS, "playerLock*");
         
         if(queryResult.wasSuccessful()) {
@@ -151,11 +152,15 @@ public class MainView {
                 ProcessResult deleteResult = RegistryUtils.delete(DEEPWORLD_PLAYERPREFS, name);
                 return deleteResult.wasSuccessful();
             } else {
-                // Might as well.
-                return true;
+                return false;
             }
         }
         
         return false;
     }
+    
+    // A bit simpler but it should to the trick just fine.
+    private boolean clearAccountLockMacOS() {
+        return ProcessUtils.executeCommand("defaults delete com.bytebin.deepworld playerLocked").wasSuccessful();
+    }
 }
diff --git a/src/main/java/brainwine/gui/ServerPanel.java b/src/main/java/brainwine/gui/ServerPanel.java
index 50a48df..efc1e7d 100644
--- a/src/main/java/brainwine/gui/ServerPanel.java
+++ b/src/main/java/brainwine/gui/ServerPanel.java
@@ -27,20 +27,20 @@ import org.apache.logging.log4j.Level;
 import com.formdev.flatlaf.extras.components.FlatScrollPane;
 import com.formdev.flatlaf.extras.components.FlatTextField;
 
-import brainwine.Bootstrap;
+import brainwine.Main;
 import brainwine.ListenableAppender;
 import brainwine.gui.event.AutoScrollAdjustmentListener;
 
 @SuppressWarnings("serial")
 public class ServerPanel extends JPanel {
     
-    private final Bootstrap bootstrap;
+    private final Main main;
     private final JTextPane consoleOutput;
     private final FlatTextField consoleInput;
     private final JButton serverButton;
     
-    public ServerPanel(Bootstrap bootstrap) {
-        this.bootstrap = bootstrap;
+    public ServerPanel(Main main) {
+        this.main = main;
         setLayout(new BorderLayout());
         setBorder(BorderFactory.createEmptyBorder(0, 3, 3, 3));
         
@@ -114,32 +114,32 @@ public class ServerPanel extends JPanel {
         
         if(!commandLine.isEmpty()) {
             appendConsoleOutput(String.format("> %s\n", commandLine), Color.GRAY);
-            bootstrap.executeCommand(commandLine);
+            main.executeCommand(commandLine);
             consoleInput.setText(null);
         }
     }
     
     private void toggleServer() {
-        if(bootstrap.isServerRunning()) {
+        if(main.isServerRunning()) {
             serverButton.setEnabled(false);
             consoleInput.setEditable(false);
             consoleInput.setText(null);
-            bootstrap.stopServer();
+            main.stopServer();
         } else {
             serverButton.setEnabled(false);
             consoleInput.setEditable(true);
             consoleOutput.setText(null);
-            bootstrap.startServer();
+            main.startServer();
         }
     }
     
     public void enableServerButton() {
-        if(!bootstrap.isServerRunning()) {
+        if(!main.isServerRunning()) {
             consoleInput.setEditable(false);
             consoleInput.setText(null);
         }
         
-        serverButton.setText(bootstrap.isServerRunning() ? "Stop Server" : "Start Server");
+        serverButton.setText(main.isServerRunning() ? "Stop Server" : "Start Server");
         serverButton.setEnabled(true);
     }
 }