From 0eea0fa13ff86d78224597e5eb626ac36b913cfa Mon Sep 17 00:00:00 2001
From: DBotThePony <dbotthepony@yandex.ru>
Date: Mon, 5 Feb 2024 03:17:30 +0700
Subject: [PATCH] More grinding on entities and their networking

---
 .../kotlin/ru/dbotthepony/kstarbound/Main.kt  |   4 +-
 .../kstarbound/client/StarboundClient.kt      |   2 +-
 .../network/packets/ForgetEntityPacket.kt     |  25 +++
 .../network/packets/SpawnWorldObjectPacket.kt |  12 +-
 .../kstarbound/client/world/ClientWorld.kt    |  38 ++---
 .../dbotthepony/kstarbound/defs/JsonDriven.kt |   4 +
 .../kstarbound/network/PacketRegistry.kt      |   2 +
 .../server/network/ServerConnection.kt        |  58 ++++---
 .../kstarbound/server/world/IChunkSaver.kt    |   4 +-
 .../kstarbound/server/world/IChunkSource.kt   |   5 +-
 .../server/world/LegacyChunkSource.kt         |   5 +-
 .../kstarbound/server/world/ServerWorld.kt    | 101 +++++++++++-
 .../ru/dbotthepony/kstarbound/world/Chunk.kt  | 104 ++++++------
 .../kstarbound/world/IChunkListener.kt        |  21 ++-
 .../ru/dbotthepony/kstarbound/world/World.kt  | 125 +++++++-------
 .../kstarbound/world/WorldGeometry.kt         |   8 +
 .../AbstractActorMovementController.kt        |   4 +-
 .../world/entities/AbstractEntity.kt          | 132 +++++++++++++++
 .../entities/AbstractMovementController.kt    |  19 ++-
 .../world/entities/DynamicEntity.kt           |  71 ++++++++
 .../kstarbound/world/entities/Entity.kt       | 155 ------------------
 .../entities/EntityActorMovementController.kt |   4 +-
 .../entities/EntityMovementController.kt      |   2 +-
 .../kstarbound/world/entities/ItemEntity.kt   |   7 +-
 .../kstarbound/world/entities/PlayerEntity.kt |  10 +-
 .../kstarbound/world/entities/TileEntity.kt   |  47 ++++++
 .../kstarbound/world/entities/WorldObject.kt  |  64 +++-----
 27 files changed, 615 insertions(+), 418 deletions(-)
 create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/ForgetEntityPacket.kt
 create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt
 create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt
 delete mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/Entity.kt
 create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt

diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt
index bea1e1c8..18fffdec 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt
@@ -70,10 +70,10 @@ fun main() {
 		val rand = Random()
 
 		for (i in 0 until 0) {
-			val item = ItemEntity(world, Registries.items.keys.values.random().value)
+			val item = ItemEntity(Registries.items.keys.values.random().value)
 
 			item.position = Vector2d(225.0 - i, 785.0)
-			item.spawn()
+			item.spawn(world)
 			item.movement.velocity = Vector2d(rand.nextDouble() * 32.0 - 16.0, rand.nextDouble() * 32.0 - 16.0)
 
 			item.mailbox.scheduleAtFixedRate({ item.movement.velocity += Vector2d(rand.nextDouble() * 32.0 - 16.0, rand.nextDouble() * 32.0 - 16.0) }, 1000 + rand.nextLong(-100, 100), 1000 + rand.nextLong(-100, 100), TimeUnit.MILLISECONDS)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt
index c738e6c5..d4a612f1 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt
@@ -962,7 +962,7 @@ class StarboundClient private constructor(val clientID: Int) : Closeable {
 		if (world != null) {
 			font.render("Camera: ${camera.pos} ${settings.zoom}", y = 140f, scale = 0.25f)
 			font.render("Cursor: $mouseCoordinates -> ${screenToWorld(mouseCoordinates)}", y = 160f, scale = 0.25f)
-			font.render("World chunk: ${world.chunkFromCell(camera.pos)}", y = 180f, scale = 0.25f)
+			font.render("World chunk: ${world.geometry.chunkFromCell(camera.pos)}", y = 180f, scale = 0.25f)
 		}
 
 		drawPerformanceBasic(false)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/ForgetEntityPacket.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/ForgetEntityPacket.kt
new file mode 100644
index 00000000..7a28598d
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/ForgetEntityPacket.kt
@@ -0,0 +1,25 @@
+package ru.dbotthepony.kstarbound.client.network.packets
+
+import ru.dbotthepony.kommons.io.readUUID
+import ru.dbotthepony.kommons.io.writeUUID
+import ru.dbotthepony.kstarbound.client.network.ClientConnection
+import ru.dbotthepony.kstarbound.network.IClientPacket
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.util.UUID
+
+class ForgetEntityPacket(val uuid: UUID) : IClientPacket {
+	constructor(buff: DataInputStream) : this(buff.readUUID())
+
+	override fun write(stream: DataOutputStream) {
+		stream.writeUUID(uuid)
+	}
+
+	override fun play(connection: ClientConnection) {
+		val world = connection.client.world ?: return
+
+		world.mailbox.execute {
+			world.entities.firstOrNull { it.uuid == uuid }?.remove()
+		}
+	}
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/SpawnWorldObjectPacket.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/SpawnWorldObjectPacket.kt
index ef553dae..56323da0 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/SpawnWorldObjectPacket.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/network/packets/SpawnWorldObjectPacket.kt
@@ -1,6 +1,8 @@
 package ru.dbotthepony.kstarbound.client.network.packets
 
 import com.google.gson.JsonObject
+import ru.dbotthepony.kommons.io.readUUID
+import ru.dbotthepony.kommons.io.writeUUID
 import ru.dbotthepony.kstarbound.client.network.ClientConnection
 import ru.dbotthepony.kstarbound.json.readJsonObject
 import ru.dbotthepony.kstarbound.json.writeJsonObject
@@ -8,11 +10,13 @@ import ru.dbotthepony.kstarbound.network.IClientPacket
 import ru.dbotthepony.kstarbound.world.entities.WorldObject
 import java.io.DataInputStream
 import java.io.DataOutputStream
+import java.util.UUID
 
-class SpawnWorldObjectPacket(val data: JsonObject) : IClientPacket {
-	constructor(stream: DataInputStream) : this(stream.readJsonObject())
+class SpawnWorldObjectPacket(val uuid: UUID, val data: JsonObject) : IClientPacket {
+	constructor(stream: DataInputStream) : this(stream.readUUID(), stream.readJsonObject())
 
 	override fun write(stream: DataOutputStream) {
+		stream.writeUUID(uuid)
 		stream.writeJsonObject(data)
 	}
 
@@ -20,8 +24,8 @@ class SpawnWorldObjectPacket(val data: JsonObject) : IClientPacket {
 		connection.client.mailbox.execute {
 			val world = connection.client.world ?: return@execute
 			val obj = WorldObject.fromJson(data)
-			val chunk = world.chunkMap[world.geometry.chunkFromCell(obj.pos)] ?: return@execute
-			chunk.addObject(obj)
+			obj.uuid = uuid
+			obj.spawn(world)
 		}
 	}
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt
index d133a5c6..92ee702c 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt
@@ -65,6 +65,10 @@ class ClientWorld(
 		return geometry.loopY || value in 0 .. renderRegionsY
 	}
 
+	override fun isSameThread(): Boolean {
+		return client.isSameThread()
+	}
+
 	inner class RenderRegion(val x: Int, val y: Int) {
 		inner class Layer(private val view: ITileAccess, private val isBackground: Boolean) {
 			val bakedMeshes = ArrayList<Pair<ConfiguredMesh<*>, RenderLayer.Point>>()
@@ -87,7 +91,7 @@ class ClientWorld(
 
 					for (x in 0 until renderRegionWidth) {
 						for (y in 0 until renderRegionHeight) {
-							if (!inBounds(x, y)) continue
+							if (!geometry.inBoundsCell(x, y)) continue
 							if (bakeTaskID != this.bakeTaskID) return@Supplier meshes
 
 							val tile = view.getTile(x, y)
@@ -241,9 +245,7 @@ class ClientWorld(
 
 		for (x in ix - paddingX .. ix + paddingX) {
 			for (y in iy - paddingY .. iy + paddingY) {
-				lock.withLock {
-					renderRegions[renderRegionKey(x, y)]?.let(action)
-				}
+				renderRegions[renderRegionKey(x, y)]?.let(action)
 			}
 		}
 	}
@@ -264,18 +266,14 @@ class ClientWorld(
 				val index = renderRegionKey(ix, iy)
 
 				if (seen.add(index)) {
-					lock.withLock {
-						renderRegions[index]?.let(action)
-					}
+					renderRegions[index]?.let(action)
 				}
 			}
 		} else {
 			val ix = pos.component1() / renderRegionWidth
 			val iy = pos.component2() / renderRegionHeight
 
-			lock.withLock {
-				renderRegions[renderRegionKey(ix, iy)]?.let(action)
-			}
+			renderRegions[renderRegionKey(ix, iy)]?.let(action)
 		}
 	}
 
@@ -304,25 +302,9 @@ class ClientWorld(
 			}
 		}
 
-		for (obj in objects) {
-			if (obj.pos.x in client.viewportCellX .. client.viewportCellX + client.viewportCellWidth && obj.pos.y in client.viewportCellY .. client.viewportCellY + client.viewportCellHeight) {
-				val layer = layers.getLayer(obj.orientation?.renderLayer ?: continue)
-
-				obj.drawables.forEach {
-					val (x, y) = obj.imagePosition
-					it.render(client, layer, obj.pos.x.toFloat() + x / PIXELS_IN_STARBOUND_UNITf, obj.pos.y.toFloat() + y / PIXELS_IN_STARBOUND_UNITf)
-				}
-
-				obj.addLights(client.viewportLighting, client.viewportCellX, client.viewportCellY)
-			}
-		}
-
 		for (ent in entities) {
-			if (ent.position.x.toInt() in client.viewportCellX .. client.viewportCellX + client.viewportCellWidth && ent.position.y.toInt() in client.viewportCellY .. client.viewportCellY + client.viewportCellHeight) {
-				layers.add(RenderLayer.Overlay.point()) {
-					ent.render(client)
-				}
-			}
+			ent.render(client, layers)
+			ent.addLights(client.viewportLighting, client.viewportCellX, client.viewportCellY)
 		}
 	}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/JsonDriven.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/JsonDriven.kt
index eec8d3b3..9b51541e 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/JsonDriven.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/JsonDriven.kt
@@ -27,6 +27,10 @@ abstract class JsonDriven(val path: String) {
 	private val namedLazies = Object2ObjectOpenHashMap<String, ArrayList<LazyData<*>>>()
 
 	protected val properties = JsonObject()
+
+	/**
+	 * [JsonObject]s which define behavior of properties
+	 */
 	protected abstract fun defs(): Collection<JsonObject>
 
 	protected open fun invalidate() {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt
index 94a1a7f0..81e93f4b 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt
@@ -11,6 +11,7 @@ import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap
 import org.apache.logging.log4j.LogManager
 import ru.dbotthepony.kstarbound.client.network.packets.ForgetChunkPacket
 import ru.dbotthepony.kstarbound.client.network.packets.ChunkCellsPacket
+import ru.dbotthepony.kstarbound.client.network.packets.ForgetEntityPacket
 import ru.dbotthepony.kstarbound.client.network.packets.JoinWorldPacket
 import ru.dbotthepony.kstarbound.client.network.packets.SpawnWorldObjectPacket
 import ru.dbotthepony.kstarbound.network.packets.DisconnectPacket
@@ -132,5 +133,6 @@ object PacketRegistry {
 		add(::TrackedPositionPacket)
 		add(::TrackedSizePacket)
 		add(::SpawnWorldObjectPacket)
+		add(::ForgetEntityPacket)
 	}
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/network/ServerConnection.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/network/ServerConnection.kt
index 543d2a8b..46580c9e 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/network/ServerConnection.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/network/ServerConnection.kt
@@ -1,12 +1,15 @@
 package ru.dbotthepony.kstarbound.server.network
 
 import io.netty.channel.ChannelHandlerContext
+import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap
 import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap
+import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet
 import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet
 import org.apache.logging.log4j.LogManager
 import ru.dbotthepony.kommons.vector.Vector2d
 import ru.dbotthepony.kstarbound.client.network.packets.ForgetChunkPacket
 import ru.dbotthepony.kstarbound.client.network.packets.ChunkCellsPacket
+import ru.dbotthepony.kstarbound.client.network.packets.ForgetEntityPacket
 import ru.dbotthepony.kstarbound.client.network.packets.SpawnWorldObjectPacket
 import ru.dbotthepony.kstarbound.network.Connection
 import ru.dbotthepony.kstarbound.network.ConnectionSide
@@ -16,6 +19,10 @@ import ru.dbotthepony.kstarbound.network.packets.HelloPacket
 import ru.dbotthepony.kstarbound.server.StarboundServer
 import ru.dbotthepony.kstarbound.server.world.ServerWorld
 import ru.dbotthepony.kstarbound.world.ChunkPos
+import ru.dbotthepony.kstarbound.world.IChunkListener
+import ru.dbotthepony.kstarbound.world.api.ImmutableCell
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
+import ru.dbotthepony.kstarbound.world.entities.WorldObject
 import java.util.*
 
 class ServerConnection(val server: StarboundServer, type: ConnectionType) : Connection(ConnectionSide.SERVER, type, UUID(0L, 0L)) {
@@ -49,14 +56,29 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn
 		}
 
 	private val tickets = Object2ObjectOpenHashMap<ChunkPos, ServerWorld.ITicket>()
-	private val sentChunks = ObjectOpenHashSet<ChunkPos>()
+	private val pendingSend = ObjectLinkedOpenHashSet<ChunkPos>()
 
 	private var needsToRecomputeTrackedChunks = true
 
+	private inner class ChunkListener(val pos: ChunkPos) : IChunkListener {
+		override fun onEntityAdded(entity: AbstractEntity) {
+			if (entity is WorldObject)
+				send(SpawnWorldObjectPacket(entity.uuid, entity.serialize()))
+		}
+
+		override fun onEntityRemoved(entity: AbstractEntity) {
+			send(ForgetEntityPacket(entity.uuid))
+		}
+
+		override fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) {
+			pendingSend.add(pos)
+		}
+	}
+
 	fun onLeaveWorld() {
 		tickets.values.forEach { it.cancel() }
 		tickets.clear()
-		sentChunks.clear()
+		pendingSend.clear()
 	}
 
 	private fun recomputeTrackedChunks() {
@@ -77,6 +99,8 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn
 
 		for ((pos, ticket) in itr) {
 			if (pos !in tracked) {
+				send(ForgetChunkPacket(pos))
+				pendingSend.remove(pos)
 				ticket.cancel()
 				itr.remove()
 			}
@@ -84,7 +108,10 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn
 
 		for (pos in tracked) {
 			if (pos !in tickets) {
-				tickets[pos] = world.permanentChunkTicket(pos)
+				val ticket = world.permanentChunkTicket(pos)
+				tickets[pos] = ticket
+				ticket.addListener(ChunkListener(pos))
+				pendingSend.add(pos)
 			}
 		}
 	}
@@ -101,29 +128,12 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn
 			recomputeTrackedChunks()
 		}
 
-		for (pos in tickets.keys) {
-			val chunk = world.chunkMap[pos] ?: continue
-
-			if (pos !in sentChunks) {
-				send(ChunkCellsPacket(chunk))
-
-				chunk.objects.forEach {
-					send(SpawnWorldObjectPacket(it.serialize()))
-				}
-
-				sentChunks.add(pos)
-			}
-
-
-		}
-
-		val itr = sentChunks.iterator()
+		val itr = pendingSend.iterator()
 
 		for (pos in itr) {
-			if (pos !in tickets) {
-				send(ForgetChunkPacket(pos))
-				itr.remove()
-			}
+			val chunk = world.chunkMap[pos] ?: continue
+			send(ChunkCellsPacket(chunk))
+			itr.remove()
 		}
 	}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSaver.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSaver.kt
index 088e4183..43913cbe 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSaver.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSaver.kt
@@ -3,9 +3,9 @@ package ru.dbotthepony.kstarbound.server.world
 import ru.dbotthepony.kommons.arrays.Object2DArray
 import ru.dbotthepony.kstarbound.world.ChunkPos
 import ru.dbotthepony.kstarbound.world.api.AbstractCell
-import ru.dbotthepony.kstarbound.world.entities.WorldObject
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
 
 interface IChunkSaver {
 	fun saveCells(pos: ChunkPos, data: Object2DArray<out AbstractCell>)
-	fun saveObjects(pos: ChunkPos, data: Collection<WorldObject>)
+	fun saveEntities(pos: ChunkPos, data: Collection<AbstractEntity>)
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSource.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSource.kt
index e07ce2d7..7c53d59a 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSource.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/IChunkSource.kt
@@ -5,19 +5,20 @@ import ru.dbotthepony.kommons.core.KOptional
 import ru.dbotthepony.kstarbound.world.CHUNK_SIZE
 import ru.dbotthepony.kstarbound.world.ChunkPos
 import ru.dbotthepony.kstarbound.world.api.AbstractCell
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
 import ru.dbotthepony.kstarbound.world.entities.WorldObject
 import java.util.concurrent.CompletableFuture
 
 interface IChunkSource {
 	fun getTiles(pos: ChunkPos): CompletableFuture<KOptional<Object2DArray<out AbstractCell>>>
-	fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>>
+	fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<AbstractEntity>>>
 
 	object Void : IChunkSource {
 		override fun getTiles(pos: ChunkPos): CompletableFuture<KOptional<Object2DArray<out AbstractCell>>> {
 			return CompletableFuture.completedFuture(KOptional.of(Object2DArray(CHUNK_SIZE, CHUNK_SIZE, AbstractCell.EMPTY)))
 		}
 
-		override fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>> {
+		override fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<AbstractEntity>>> {
 			return CompletableFuture.completedFuture(KOptional.of(emptyList()))
 		}
 	}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyChunkSource.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyChunkSource.kt
index e6f0e447..8e26ae8a 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyChunkSource.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyChunkSource.kt
@@ -12,6 +12,7 @@ import ru.dbotthepony.kstarbound.world.CHUNK_SIZE
 import ru.dbotthepony.kstarbound.world.ChunkPos
 import ru.dbotthepony.kstarbound.world.api.AbstractCell
 import ru.dbotthepony.kstarbound.world.api.MutableCell
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
 import ru.dbotthepony.kstarbound.world.entities.WorldObject
 import java.io.BufferedInputStream
 import java.io.ByteArrayInputStream
@@ -43,7 +44,7 @@ class LegacyChunkSource(val db: BTreeDB) : IChunkSource {
 		}
 	}
 
-	override fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>> {
+	override fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<AbstractEntity>>> {
 		return CompletableFuture.supplyAsync {
 			val chunkX = pos.x
 			val chunkY = pos.y
@@ -52,7 +53,7 @@ class LegacyChunkSource(val db: BTreeDB) : IChunkSource {
 
 			val reader = DataInputStream(BufferedInputStream(InflaterInputStream(ByteArrayInputStream(data), Inflater())))
 			val i = reader.readVarInt()
-			val objects = ArrayList<WorldObject>()
+			val objects = ArrayList<AbstractEntity>()
 
 			for (i2 in 0 until i) {
 				val obj = VersionedJson(reader)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt
index 40db2330..e71f43ac 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt
@@ -3,6 +3,8 @@ package ru.dbotthepony.kstarbound.server.world
 import it.unimi.dsi.fastutil.longs.Long2ObjectFunction
 import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap
 import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet
+import it.unimi.dsi.fastutil.objects.ReferenceLinkedOpenHashSet
+import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet
 import ru.dbotthepony.kommons.collect.chainOptionalFutures
 import ru.dbotthepony.kommons.core.KOptional
 import ru.dbotthepony.kstarbound.Starbound
@@ -11,13 +13,20 @@ import ru.dbotthepony.kstarbound.server.StarboundServer
 import ru.dbotthepony.kstarbound.server.network.ServerConnection
 import ru.dbotthepony.kstarbound.util.ExecutionSpinner
 import ru.dbotthepony.kstarbound.world.ChunkPos
+import ru.dbotthepony.kstarbound.world.ICellChangeListener
+import ru.dbotthepony.kstarbound.world.IChunkListener
+import ru.dbotthepony.kstarbound.world.IEntityAdditionListener
+import ru.dbotthepony.kstarbound.world.IEntityRemovalListener
 import ru.dbotthepony.kstarbound.world.World
 import ru.dbotthepony.kstarbound.world.WorldGeometry
+import ru.dbotthepony.kstarbound.world.api.ImmutableCell
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
 import java.util.Collections
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.RejectedExecutionException
 import java.util.concurrent.atomic.AtomicInteger
 import java.util.concurrent.locks.LockSupport
+import java.util.concurrent.locks.ReentrantLock
 import java.util.function.Consumer
 import java.util.function.Supplier
 import kotlin.concurrent.withLock
@@ -68,6 +77,7 @@ class ServerWorld(
 
 	val spinner = ExecutionSpinner(mailbox, ::spin, Starbound.TICK_TIME_ADVANCE_NANOS)
 	val thread = Thread(spinner, "Starbound Server World $seed")
+	val ticketListLock = ReentrantLock()
 
 	@Volatile
 	var isClosed: Boolean = false
@@ -114,10 +124,14 @@ class ServerWorld(
 	override val isRemote: Boolean
 		get() = false
 
-	override fun thinkInner() {
-		lock.withLock {
-			internalPlayers.forEach { it.tick() }
+	override fun isSameThread(): Boolean {
+		return Thread.currentThread() === thread
+	}
 
+	override fun thinkInner() {
+		internalPlayers.forEach { if (!isClosed) it.tick() }
+
+		ticketListLock.withLock {
 			ticketLists.removeIf {
 				val valid = it.tick()
 
@@ -128,7 +142,15 @@ class ServerWorld(
 					val chunk = chunkMap[it.pos]
 
 					if (chunk != null) {
+						val unloadable = chunk.entities.filter { it.isApplicableForUnloading }
+
 						saver?.saveCells(it.pos, chunk.copyCells())
+						saver?.saveEntities(it.pos, unloadable)
+
+						unloadable.forEach {
+							it.remove()
+						}
+
 						chunkMap.remove(it.pos)
 					}
 				}
@@ -168,6 +190,11 @@ class ServerWorld(
 		val isCanceled: Boolean
 		val pos: ChunkPos
 		val id: Int
+
+		val chunk: ServerChunk?
+
+		fun addListener(listener: IChunkListener)
+		fun removeListener(listener: IChunkListener)
 	}
 
 	interface ITimedTicket : ITicket, Comparable<ITimedTicket> {
@@ -181,7 +208,7 @@ class ServerWorld(
 		}
 	}
 
-	private inner class TicketList(val pos: ChunkPos) {
+	private inner class TicketList(val pos: ChunkPos) : IChunkListener, IChunkMapListener<ServerChunk> {
 		constructor(pos: Long) : this(ChunkPos(pos))
 
 		private var first = true
@@ -189,6 +216,7 @@ class ServerWorld(
 		private val temporary = ObjectAVLTreeSet<TimedTicket>()
 		private var ticks = 0
 		private var nextTicketID = AtomicInteger()
+		private var weAreResponsibleForLoadingTheChunk = false
 
 		val isValid: Boolean
 			get() = temporary.isNotEmpty() || permanent.isNotEmpty()
@@ -205,7 +233,34 @@ class ServerWorld(
 			return temporary.isNotEmpty() || permanent.isNotEmpty()
 		}
 
-		abstract inner class AbstractTicket : ITicket {
+		override fun onEntityAdded(entity: AbstractEntity) {
+			permanent.forEach { it.onEntityAdded(entity) }
+			temporary.forEach { it.onEntityAdded(entity) }
+		}
+
+		override fun onEntityRemoved(entity: AbstractEntity) {
+			permanent.forEach { it.onEntityRemoved(entity) }
+			temporary.forEach { it.onEntityRemoved(entity) }
+		}
+
+		override fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) {
+			permanent.forEach { it.onCellChanges(x, y, cell) }
+			temporary.forEach { it.onCellChanges(x, y, cell) }
+		}
+
+		override fun onChunkCreated(chunk: ServerChunk) {
+			if (chunk.pos == pos) {
+				chunk.addListener(this)
+			}
+		}
+
+		override fun onChunkRemoved(chunk: ServerChunk) {
+			if (chunk.pos == pos) {
+				chunk.removeListener(this)
+			}
+		}
+
+		abstract inner class AbstractTicket : ITicket, IChunkListener {
 			final override val id: Int = nextTicketID.getAndIncrement()
 			final override val pos: ChunkPos
 				get() = this@TicketList.pos
@@ -218,8 +273,11 @@ class ServerWorld(
 
 					if (geometry.x.inBoundsChunk(pos.x) && geometry.y.inBoundsChunk(pos.y)) {
 						ticketLists.add(this@TicketList)
+						chunkMap.addListener(this@TicketList)
+
+						if (chunkProviders.isNotEmpty() && chunkMap[pos] == null) {
+							weAreResponsibleForLoadingTheChunk = true
 
-						if (chunkProviders.isNotEmpty()) {
 							chainOptionalFutures(chunkProviders)
 							{ if (!isValid) CompletableFuture.completedFuture(KOptional.empty()) else it.getTiles(pos) }
 								.thenAccept(Consumer { tiles ->
@@ -234,7 +292,7 @@ class ServerWorld(
 
 											ents.ifPresent {
 												for (obj in it) {
-													chunk.addObject(obj)
+													obj.spawn(this@ServerWorld)
 												}
 											}
 										}, mailbox)
@@ -250,11 +308,40 @@ class ServerWorld(
 				lock.withLock {
 					if (isCanceled) return
 					isCanceled = true
+					chunk?.entities?.forEach { e -> listeners.forEach { it.onEntityRemoved(e) } }
 					onCancel()
 				}
 			}
 
 			protected abstract fun onCancel()
+			final override val chunk: ServerChunk?
+				get() = chunkMap[pos]
+
+			private val listeners = ReferenceLinkedOpenHashSet<IChunkListener>()
+
+			final override fun addListener(listener: IChunkListener) {
+				if (isCanceled) return
+				listeners.add(listener)
+				chunk?.entities?.forEach { listener.onEntityAdded(it) }
+			}
+
+			final override fun removeListener(listener: IChunkListener) {
+				if (listeners.remove(listener)) {
+					chunk?.entities?.forEach { listener.onEntityRemoved(it) }
+				}
+			}
+
+			final override fun onEntityAdded(entity: AbstractEntity) {
+				listeners.forEach { it.onEntityAdded(entity) }
+			}
+
+			final override fun onEntityRemoved(entity: AbstractEntity) {
+				listeners.forEach { it.onEntityRemoved(entity) }
+			}
+
+			final override fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) {
+				listeners.forEach { it.onCellChanges(x, y, cell) }
+			}
 		}
 
 		inner class Ticket : AbstractTicket() {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt
index ab69517e..3149bd90 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt
@@ -11,8 +11,10 @@ import ru.dbotthepony.kstarbound.world.api.ICellAccess
 import ru.dbotthepony.kstarbound.world.api.ImmutableCell
 import ru.dbotthepony.kstarbound.world.api.OffsetCellAccess
 import ru.dbotthepony.kstarbound.world.api.TileView
-import ru.dbotthepony.kstarbound.world.entities.Entity
-import ru.dbotthepony.kstarbound.world.entities.WorldObject
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
+import ru.dbotthepony.kstarbound.world.entities.DynamicEntity
+import ru.dbotthepony.kstarbound.world.entities.TileEntity
+import java.util.concurrent.CompletableFuture
 import kotlin.concurrent.withLock
 
 /**
@@ -44,8 +46,9 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 	var backgroundChangeset = 0
 		private set
 
-	val entities = ReferenceOpenHashSet<Entity>()
-	val objects = ReferenceOpenHashSet<WorldObject>()
+	val entities = ReferenceOpenHashSet<AbstractEntity>()
+	val dynamicEntities = ReferenceOpenHashSet<DynamicEntity>()
+	val tileEntities = ReferenceOpenHashSet<TileEntity>()
 	protected val subscribers = ObjectArraySet<IChunkListener>()
 
 	// local cells' tile access
@@ -136,7 +139,7 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 		changeset++
 		cellChangeset++
 
-		subscribers.forEach { it.cellChanges(x, y, cell) }
+		subscribers.forEach { it.onCellChanges(x, y, cell) }
 	}
 
 	protected inline fun forEachNeighbour(block: (This) -> Unit) {
@@ -154,53 +157,69 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 		return world.randomLongFor(x or pos.x shl CHUNK_SIZE_BITS, y or pos.y shl CHUNK_SIZE_BITS)
 	}
 
-	fun addListener(subscriber: IChunkListener) {
-		subscribers.add(subscriber)
+	fun addListener(subscriber: IChunkListener): Boolean {
+		return subscribers.add(subscriber)
 	}
 
-	fun removeListener(subscriber: IChunkListener) {
-		subscribers.remove(subscriber)
+	fun removeListener(subscriber: IChunkListener): Boolean {
+		return subscribers.remove(subscriber)
 	}
 
-	fun addEntity(entity: Entity) {
+	fun addEntity(entity: AbstractEntity) {
 		world.lock.withLock {
-			if (!entities.add(entity)) {
+			if (!entities.add(entity))
 				throw IllegalArgumentException("Already having having entity $entity")
-			}
+
+			if (entity is TileEntity)
+				tileEntities.add(entity)
+
+			if (entity is DynamicEntity)
+				dynamicEntities.add(entity)
 
 			changeset++
 			subscribers.forEach { it.onEntityAdded(entity) }
 		}
 	}
 
-	fun transferEntity(entity: Entity, otherChunk: Chunk<*, *>) {
+	fun transferEntity(entity: AbstractEntity, otherChunk: Chunk<*, *>) {
 		world.lock.withLock {
 			if (otherChunk == this)
 				throw IllegalArgumentException("what?")
 
-			if (this::class.java != otherChunk::class.java) {
-				throw IllegalArgumentException("Incompatible types: $this !is $otherChunk")
-			}
-
-			if (!entities.add(entity)) {
-				throw IllegalArgumentException("Already containing $entity")
-			}
+			if (world != otherChunk.world)
+				throw IllegalArgumentException("Chunks belong to different worlds: this: $this / other: $otherChunk")
 
 			changeset++
+			otherChunk.changeset++
+
+			entities.add(entity)
+			otherChunk.entities.remove(entity)
+
+			if (entity is TileEntity) {
+				tileEntities.add(entity)
+				otherChunk.tileEntities.remove(entity)
+			}
+
+			if (entity is DynamicEntity) {
+				dynamicEntities.add(entity)
+				otherChunk.dynamicEntities.remove(entity)
+			}
+
 			otherChunk.subscribers.forEach { it.onEntityRemoved(entity) }
 			subscribers.forEach { it.onEntityAdded(entity) }
-
-			if (!otherChunk.entities.remove(entity)) {
-				throw IllegalStateException("Unable to remove $entity from $otherChunk after transfer")
-			}
 		}
 	}
 
-	fun removeEntity(entity: Entity) {
+	fun removeEntity(entity: AbstractEntity) {
 		world.lock.withLock {
-			if (!entities.remove(entity)) {
+			if (!entities.remove(entity))
 				throw IllegalArgumentException("Already not having entity $entity")
-			}
+
+			if (entity is TileEntity)
+				tileEntities.remove(entity)
+
+			if (entity is DynamicEntity)
+				dynamicEntities.remove(entity)
 
 			changeset++
 			subscribers.forEach { it.onEntityRemoved(entity) }
@@ -211,39 +230,8 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 		return "${this::class.simpleName}(pos=$pos, entityCount=${entities.size}, world=$world)"
 	}
 
-	fun addObject(obj: WorldObject) {
-		world.lock.withLock {
-			if (!objects.add(obj))
-				throw IllegalStateException("$this already has object $obj!")
-
-			if (!world.objects.add(obj))
-				throw IllegalStateException("World $world already has object $obj!")
-
-			obj.spawn(world)
-			subscribers.forEach { it.onObjectAdded(obj) }
-		}
-	}
-
-	fun removeObject(obj: WorldObject) {
-		world.lock.withLock {
-			if (!objects.remove(obj))
-				throw IllegalStateException("$this does not have object $obj!")
-
-			if (!world.objects.remove(obj))
-				throw IllegalStateException("World $world does not have object $obj!")
-
-			subscribers.forEach { it.onObjectRemoved(obj) }
-		}
-	}
-
 	open fun remove() {
 		world.lock.withLock {
-			for (obj in ObjectArrayList(objects)) {
-				if (!world.objects.remove(obj)) {
-					throw IllegalStateException("World $world does not have object $obj!")
-				}
-			}
-
 			for (ent in ObjectArrayList(entities)) {
 				ent.chunk = null
 			}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt
index ced4c475..970943b0 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt
@@ -1,13 +1,18 @@
 package ru.dbotthepony.kstarbound.world
 
 import ru.dbotthepony.kstarbound.world.api.ImmutableCell
-import ru.dbotthepony.kstarbound.world.entities.Entity
-import ru.dbotthepony.kstarbound.world.entities.WorldObject
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
 
-interface IChunkListener {
-	fun onEntityAdded(entity: Entity)
-	fun onEntityRemoved(entity: Entity)
-	fun onObjectAdded(obj: WorldObject)
-	fun onObjectRemoved(obj: WorldObject)
-	fun cellChanges(x: Int, y: Int, cell: ImmutableCell)
+fun interface IEntityAdditionListener {
+	fun onEntityAdded(entity: AbstractEntity)
 }
+
+fun interface IEntityRemovalListener {
+	fun onEntityRemoved(entity: AbstractEntity)
+}
+
+fun interface ICellChangeListener {
+	fun onCellChanges(x: Int, y: Int, cell: ImmutableCell)
+}
+
+interface IChunkListener : IEntityAdditionListener, IEntityRemovalListener, ICellChangeListener
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt
index a43d8c98..1eddfbe8 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt
@@ -1,9 +1,7 @@
 package ru.dbotthepony.kstarbound.world
 
 import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap
-import it.unimi.dsi.fastutil.objects.ObjectArrayList
 import it.unimi.dsi.fastutil.objects.ObjectArraySet
-import it.unimi.dsi.fastutil.objects.ReferenceLinkedOpenHashSet
 import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet
 import ru.dbotthepony.kommons.arrays.Object2DArray
 import ru.dbotthepony.kommons.collect.filterNotNull
@@ -17,8 +15,9 @@ import ru.dbotthepony.kstarbound.util.ParallelPerform
 import ru.dbotthepony.kstarbound.world.api.ICellAccess
 import ru.dbotthepony.kstarbound.world.api.AbstractCell
 import ru.dbotthepony.kstarbound.world.api.TileView
-import ru.dbotthepony.kstarbound.world.entities.Entity
-import ru.dbotthepony.kstarbound.world.entities.WorldObject
+import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
+import ru.dbotthepony.kstarbound.world.entities.DynamicEntity
+import ru.dbotthepony.kstarbound.world.entities.TileEntity
 import ru.dbotthepony.kstarbound.world.physics.CollisionPoly
 import ru.dbotthepony.kstarbound.world.physics.CollisionType
 import ru.dbotthepony.kstarbound.world.physics.Poly
@@ -30,21 +29,11 @@ import java.util.concurrent.locks.ReentrantLock
 import java.util.function.Predicate
 import java.util.random.RandomGenerator
 import java.util.stream.Stream
-import kotlin.concurrent.withLock
 
 abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, ChunkType>>(
 	val seed: Long,
 	val geometry: WorldGeometry,
 ) : ICellAccess, Closeable {
-	// whenever provided cell position is within actual world borders, ignoring wrapping
-	fun inBounds(x: Int, y: Int) = geometry.x.inBoundsCell(x) && geometry.y.inBoundsCell(y)
-	fun inBounds(value: IStruct2i) = geometry.x.inBoundsCell(value.component1()) && geometry.y.inBoundsCell(value.component2())
-
-	fun chunkFromCell(x: Int, y: Int) = ChunkPos(geometry.x.chunkFromCell(x), geometry.y.chunkFromCell(y))
-	fun chunkFromCell(x: Double, y: Double) = ChunkPos(geometry.x.chunkFromCell(x.toInt()), geometry.y.chunkFromCell(y.toInt()))
-	fun chunkFromCell(value: IStruct2i) = chunkFromCell(value.component1(), value.component2())
-	fun chunkFromCell(value: IStruct2d) = chunkFromCell(value.component1(), value.component2())
-
 	val background = TileView.Background(this)
 	val foreground = TileView.Foreground(this)
 	val mailbox = MailboxExecutorService()
@@ -64,6 +53,11 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 		return chunkMap.setCell(x, y, cell)
 	}
 
+	interface IChunkMapListener<ChunkType : Chunk<*, *>> {
+		fun onChunkCreated(chunk: ChunkType) { }
+		fun onChunkRemoved(chunk: ChunkType) { }
+	}
+
 	abstract inner class ChunkMap : Iterable<ChunkType> {
 		abstract operator fun get(x: Int, y: Int): ChunkType?
 		abstract fun compute(x: Int, y: Int): ChunkType?
@@ -77,15 +71,23 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 
 		operator fun get(pos: ChunkPos) = get(pos.x, pos.y)
 
+		protected val listeners = ReferenceOpenHashSet<IChunkMapListener<ChunkType>>()
+
+		fun addListener(listener: IChunkMapListener<ChunkType>) {
+			listeners.add(listener)
+		}
+
+		fun removeListener(listener: IChunkMapListener<ChunkType>) {
+			listeners.remove(listener)
+		}
+
 		protected fun create(x: Int, y: Int): ChunkType {
 			val pos = ChunkPos(x, y)
 			val chunk = chunkFactory(pos)
-			val orphanedInThisChunk = ArrayList<Entity>()
+			val orphanedInThisChunk = ArrayList<AbstractEntity>()
 
 			for (ent in orphanedEntities) {
-				val (ex, ey) = ent.position
-
-				if (geometry.x.chunkFromCell(ex) == x && geometry.y.chunkFromCell(ey) == y) {
+				if (ent.chunkPos == pos) {
 					orphanedInThisChunk.add(ent)
 				}
 			}
@@ -94,6 +96,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 				ent.chunk = chunk
 			}
 
+			listeners.forEach { it.onChunkCreated(chunk) }
 			return chunk
 		}
 
@@ -120,11 +123,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 			if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null
 
 			val index = ChunkPos.toLong(x, y)
-
-			val get = map[index] ?: lock.withLock {
-				map[index] ?: create(x, y).also { map[index] = it }
-			}
-
+			val get = map[index] ?: create(x, y).also { map[index] = it }
 			return get
 		}
 
@@ -137,16 +136,18 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 
 			val index = ChunkPos.toLong(cx, cy)
 
-			val get = map[index] ?: lock.withLock {
-				map[index] ?: create(cx, cy).also { map[index] = it }
-			}
-
+			val get = map[index] ?: create(cx, cy).also { map[index] = it }
 			return get.setCell(ix and CHUNK_SIZE_MASK, iy and CHUNK_SIZE_MASK, cell)
 		}
 
 		override fun remove(x: Int, y: Int) {
-			lock.withLock {
-				map.remove(ChunkPos.toLong(geometry.x.chunk(x), geometry.y.chunk(y)))?.remove()
+			val index = ChunkPos.toLong(geometry.x.chunk(x), geometry.y.chunk(y))
+			val chunk = map.get(index)
+
+			if (chunk != null) {
+				chunk.remove()
+				listeners.forEach { it.onChunkRemoved(chunk) }
+				map.remove(index)
 			}
 		}
 
@@ -168,7 +169,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 
 		override fun compute(x: Int, y: Int): ChunkType? {
 			if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null
-			return map[x, y] ?: lock.withLock { map[x, y] ?: create(x, y).also { existing.add(ChunkPos(x, y)); map[x, y] = it } }
+			return map[x, y] ?: create(x, y).also { existing.add(ChunkPos(x, y)); map[x, y] = it }
 		}
 
 		override fun getCell(x: Int, y: Int): AbstractCell {
@@ -190,17 +191,17 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 			return map[x, y]
 		}
 
+		@Suppress("NAME_SHADOWING")
 		override fun remove(x: Int, y: Int) {
-			lock.withLock {
-				val x = geometry.x.chunk(x)
-				val y = geometry.y.chunk(y)
-				val get = map[x, y]
+			val x = geometry.x.chunk(x)
+			val y = geometry.y.chunk(y)
+			val chunk = map[x, y]
 
-				if (get != null) {
-					existing.remove(ChunkPos(x, y))
-					get.remove()
-					map[x, y] = null
-				}
+			if (chunk != null) {
+				chunk.remove()
+				listeners.forEach { it.onChunkRemoved(chunk) }
+				existing.remove(ChunkPos(x, y))
+				map[x, y] = null
 			}
 		}
 
@@ -229,40 +230,32 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 	var gravity = Vector2d(0.0, -80.0)
 	abstract val isRemote: Boolean
 
-	// used to synchronize read/writes to various world state stuff/memory structure
+	// generic lock
 	val lock = ReentrantLock()
 
+	val orphanedEntities = ReferenceOpenHashSet<AbstractEntity>()
+	val entities = ReferenceOpenHashSet<AbstractEntity>()
+	val dynamicEntities = ReferenceOpenHashSet<DynamicEntity>()
+	val tileEntities = ReferenceOpenHashSet<TileEntity>()
+
+	abstract fun isSameThread(): Boolean
+
+	fun ensureSameThread() {
+		check(isSameThread()) { "Trying to access $this from ${Thread.currentThread()}" }
+	}
+
 	fun think() {
 		try {
 			mailbox.executeQueuedTasks()
-			val entities = ObjectArrayList(entities)
-			ForkJoinPool.commonPool().submit(ParallelPerform(entities.spliterator(), { it.movement.move() })).join()
+
+			ForkJoinPool.commonPool().submit(ParallelPerform(dynamicEntities.spliterator(), { it.movement.move() })).join()
 			mailbox.executeQueuedTasks()
 
-			for (ent in entities) {
-				ent.thinkShared()
-
-				if (isRemote)
-					ent.thinkClient()
-				else
-					ent.thinkServer()
-			}
-
+			entities.forEach { it.think() }
 			mailbox.executeQueuedTasks()
 
-			lock
-				.withLock { ObjectArrayList(chunkMap.iterator()) }
-				.forEach { it.think() }
-
-			val objects = ObjectArrayList(objects)
-
-			for (ent in objects) {
-				ent.thinkShared()
-
-				if (isRemote)
-					ent.thinkClient()
-				else
-					ent.thinkServer()
+			for (chunk in chunkMap) {
+				chunk.think()
 			}
 
 			mailbox.executeQueuedTasks()
@@ -274,10 +267,6 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 
 	protected abstract fun thinkInner()
 
-	val orphanedEntities = ReferenceOpenHashSet<Entity>()
-	val entities = ReferenceLinkedOpenHashSet<Entity>()
-	val objects = ReferenceLinkedOpenHashSet<WorldObject>()
-
 	protected abstract fun chunkFactory(pos: ChunkPos): ChunkType
 
 	override fun close() {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt
index ab2fb30c..72deb0f3 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt
@@ -42,6 +42,14 @@ data class WorldGeometry(val size: Vector2i, val loopX: Boolean, val loopY: Bool
 		return ChunkPos(x.chunkFromCell(pos.component1()), y.chunkFromCell(pos.component2()))
 	}
 
+	fun inBoundsCell(pos: IStruct2i): Boolean {
+		return x.inBoundsCell(pos.component1()) && y.inBoundsCell(pos.component2())
+	}
+
+	fun inBoundsCell(x: Int, y: Int): Boolean {
+		return this.x.inBoundsCell(x) && this.y.inBoundsCell(y)
+	}
+
 	fun wrap(pos: ChunkPos): ChunkPos {
 		val x = this.x.chunk(pos.x)
 		val y = this.y.chunk(pos.y)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractActorMovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractActorMovementController.kt
index 04424464..4fc39545 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractActorMovementController.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractActorMovementController.kt
@@ -79,7 +79,7 @@ abstract class AbstractActorMovementController : AbstractMovementController() {
 	// this is set internally on each move step
 	final override var movementParameters: MovementParameters = MovementParameters.EMPTY
 
-	abstract var anchorEntity: Entity?
+	abstract var anchorEntity: DynamicEntity?
 
 	var pathController: PathController? = null
 	var groundMovementSustainTimer: GameTimer = GameTimer(0.0)
@@ -194,7 +194,7 @@ abstract class AbstractActorMovementController : AbstractMovementController() {
 	override fun move() {
 		// TODO: anchor entity
 
-		if (anchorEntity?.isRemoved == true)
+		if (anchorEntity?.isSpawned != true)
 			anchorEntity = null
 
 		val anchorEntity = anchorEntity
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt
new file mode 100644
index 00000000..0dcb4861
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt
@@ -0,0 +1,132 @@
+package ru.dbotthepony.kstarbound.world.entities
+
+import ru.dbotthepony.kommons.util.MailboxExecutorService
+import ru.dbotthepony.kstarbound.client.StarboundClient
+import ru.dbotthepony.kstarbound.client.render.LayeredRenderer
+import ru.dbotthepony.kstarbound.defs.JsonDriven
+import ru.dbotthepony.kstarbound.world.Chunk
+import ru.dbotthepony.kstarbound.world.ChunkPos
+import ru.dbotthepony.kstarbound.world.LightCalculator
+import ru.dbotthepony.kstarbound.world.World
+import java.util.UUID
+import kotlin.concurrent.withLock
+
+abstract class AbstractEntity(path: String) : JsonDriven(path) {
+	/**
+	 * The chunk this entity resides in
+	 */
+	var chunk: Chunk<*, *>? = null
+		set(value) {
+			if (innerWorld == null) {
+				throw IllegalStateException("Trying to set chunk this entity belong to before spawning in world")
+			} else if (value != null && innerWorld != value.world) {
+				throw IllegalArgumentException("$this belongs to $innerWorld, $value belongs to ${value.world}")
+			} else if (value == field) {
+				return
+			}
+
+			val oldChunk = field
+			field = value
+
+			world.lock.withLock {
+				if (oldChunk == null && value != null) {
+					world.orphanedEntities.remove(this)
+					value.addEntity(this)
+				} else if (oldChunk != null && value == null) {
+					world.orphanedEntities.add(this)
+					oldChunk.removeEntity(this)
+				} else if (oldChunk != null && value != null) {
+					value.transferEntity(this, oldChunk)
+				}
+			}
+		}
+
+	var uuid: UUID = UUID.randomUUID()
+	abstract val chunkPos: ChunkPos
+
+	var mailbox = MailboxExecutorService()
+		private set
+
+	private var innerWorld: World<*, *>? = null
+
+	val world: World<*, *>
+		get() = innerWorld ?: throw IllegalStateException("Not in world")
+
+	val isSpawned: Boolean
+		get() = innerWorld != null
+
+	/**
+	 * Whenever this entity should be removed when chunk containing it is being unloaded
+	 *
+	 * Returning false will also stop entity from being saved to disk, and render entity orphaned
+	 * when chunk containing it will get unloaded
+	 */
+	open val isApplicableForUnloading: Boolean
+		get() = true
+
+	protected open fun onSpawn(world: World<*, *>) { }
+	protected open fun onRemove(world: World<*, *>) { }
+
+	/**
+	 * MUST be called by [World] itself
+	 */
+	fun spawn(world: World<*, *>) {
+		if (innerWorld != null)
+			throw IllegalStateException("Already spawned (in world $innerWorld)")
+
+		world.ensureSameThread()
+
+		if (mailbox.isShutdown)
+			mailbox = MailboxExecutorService()
+
+		innerWorld = world
+		world.entities.add(this)
+		world.orphanedEntities.add(this)
+		onSpawn(world)
+	}
+
+	fun remove() {
+		val world = innerWorld ?: throw IllegalStateException("Not in world")
+		world.ensureSameThread()
+
+		mailbox.shutdownNow()
+		chunk = null
+		world.entities.remove(this)
+		world.orphanedEntities.remove(this)
+		onRemove(world)
+		innerWorld = null
+	}
+
+	open val isRemote: Boolean
+		get() = innerWorld?.isRemote ?: false
+
+	fun think() {
+		thinkShared()
+
+		if (isRemote) {
+			thinkRemote()
+		} else {
+			thinkLocal()
+		}
+	}
+
+	protected open fun thinkShared() {
+		mailbox.executeQueuedTasks()
+	}
+
+	protected open fun thinkRemote() {
+
+	}
+
+	protected open fun thinkLocal() {
+
+	}
+
+	open fun render(client: StarboundClient, layers: LayeredRenderer) {
+
+	}
+
+	open fun addLights(lightCalculator: LightCalculator, xOffset: Int, yOffset: Int) {
+
+	}
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractMovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractMovementController.kt
index 316af504..5257b667 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractMovementController.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractMovementController.kt
@@ -341,7 +341,7 @@ abstract class AbstractMovementController {
 
 		if (slopeCorrection) {
 			// Starbound: First try separating with our ground sliding cheat.
-			separation = collisionSeparate(checkBody, sorted, ignorePlatforms, maximumPlatformCorrection, true, Entity.SEPARATION_TOLERANCE)
+			separation = collisionSeparate(checkBody, sorted, ignorePlatforms, maximumPlatformCorrection, true, SEPARATION_TOLERANCE)
 			totalCorrection += separation.correction
 			checkBody += separation.correction
 			maxCollided = maxCollided.maxOf(separation.collisionType)
@@ -360,7 +360,7 @@ abstract class AbstractMovementController {
 
 			// KStarbound: if we got pushed into world geometry, then consider slide cheat didn't find a solution
 			if (separation.solutionFound) {
-				separation.solutionFound = staticBodies.all { it.poly.intersect(checkBody).let { it == null || it.penetration.absoluteValue <= Entity.SEPARATION_TOLERANCE } }
+				separation.solutionFound = staticBodies.all { it.poly.intersect(checkBody).let { it == null || it.penetration.absoluteValue <= SEPARATION_TOLERANCE } }
 			}
 		}
 
@@ -369,8 +369,8 @@ abstract class AbstractMovementController {
 			totalCorrection = Vector2d.ZERO
 			movingCollisionId = null
 
-			for (i in 0 until Entity.SEPARATION_STEPS) {
-				separation = collisionSeparate(checkBody, sorted, ignorePlatforms, maximumPlatformCorrection, false, Entity.SEPARATION_TOLERANCE)
+			for (i in 0 until SEPARATION_STEPS) {
+				separation = collisionSeparate(checkBody, sorted, ignorePlatforms, maximumPlatformCorrection, false, SEPARATION_TOLERANCE)
 				totalCorrection += separation.correction
 				checkBody += separation.correction
 				maxCollided = maxCollided.maxOf(separation.collisionType)
@@ -388,8 +388,8 @@ abstract class AbstractMovementController {
 			checkBody = body
 			totalCorrection = -movement
 
-			for (i in 0 until Entity.SEPARATION_STEPS) {
-				separation = collisionSeparate(checkBody, sorted, true, maximumPlatformCorrection, false, Entity.SEPARATION_TOLERANCE)
+			for (i in 0 until SEPARATION_STEPS) {
+				separation = collisionSeparate(checkBody, sorted, true, maximumPlatformCorrection, false, SEPARATION_TOLERANCE)
 				totalCorrection += separation.correction
 				checkBody += separation.correction
 				maxCollided = maxCollided.maxOf(separation.collisionType)
@@ -408,7 +408,7 @@ abstract class AbstractMovementController {
 				movement = movement + totalCorrection,
 				correction = totalCorrection,
 				isStuck = false,
-				isOnGround = -totalCorrection.dot(determineGravity()) > Entity.SEPARATION_TOLERANCE,
+				isOnGround = -totalCorrection.dot(determineGravity()) > SEPARATION_TOLERANCE,
 				movingCollisionId = movingCollisionId,
 				collisionType = maxCollided,
 				// groundSlope = Vector2d.POSITIVE_Y,
@@ -489,4 +489,9 @@ abstract class AbstractMovementController {
 
 		return separation
 	}
+
+	companion object {
+		const val SEPARATION_STEPS = 3
+		const val SEPARATION_TOLERANCE = 0.001
+	}
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt
new file mode 100644
index 00000000..1a813710
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt
@@ -0,0 +1,71 @@
+package ru.dbotthepony.kstarbound.world.entities
+
+import ru.dbotthepony.kommons.math.RGBAColor
+import ru.dbotthepony.kommons.util.AABB
+import ru.dbotthepony.kommons.vector.Vector2d
+import ru.dbotthepony.kstarbound.client.StarboundClient
+import ru.dbotthepony.kstarbound.client.render.LayeredRenderer
+import ru.dbotthepony.kstarbound.client.render.RenderLayer
+import ru.dbotthepony.kstarbound.world.ChunkPos
+import ru.dbotthepony.kstarbound.world.World
+
+/**
+ * Entities with dynamics (Player, Drops, Projectiles, NPCs, etc)
+ */
+abstract class DynamicEntity(path: String) : AbstractEntity(path) {
+	private var forceChunkRepos = false
+
+	var position = Vector2d()
+		set(value) {
+			val old = field
+
+			if (isSpawned) {
+				field = world.geometry.wrap(value)
+
+				val oldChunkPos = world.geometry.chunkFromCell(old)
+				val newChunkPos = world.geometry.chunkFromCell(field)
+
+				chunkPos = newChunkPos
+
+				if (oldChunkPos != newChunkPos || forceChunkRepos) {
+					chunk = world.chunkMap[newChunkPos]
+					forceChunkRepos = false
+				}
+			} else {
+				field = value
+			}
+		}
+
+	abstract val movement: AbstractMovementController
+	final override var chunkPos: ChunkPos = ChunkPos.ZERO
+		private set
+
+	override fun onSpawn(world: World<*, *>) {
+		world.dynamicEntities.add(this)
+		forceChunkRepos = true
+		position = position
+	}
+
+	override fun onRemove(world: World<*, *>) {
+		world.dynamicEntities.remove(this)
+	}
+
+	override fun render(client: StarboundClient, layers: LayeredRenderer) {
+		layers.add(RenderLayer.Overlay.point()) {
+			val hitboxes = movement.localHitboxes.toList()
+			if (hitboxes.isEmpty()) return@add
+
+			hitboxes.forEach { it.render(client) }
+
+			world.queryCollisions(
+				hitboxes.stream().map { it.aabb }.reduce(AABB::combine).get().enlarge(2.0, 2.0)
+			).filter(movement::shouldCollideWithBody).forEach { it.poly.render(client, BLOCK_COLLISION_COLOR) }
+		}
+	}
+
+	companion object {
+		val BLOCK_COLLISION_COLOR = RGBAColor(65, 179, 217)
+		const val SEPARATION_STEPS = 3
+		const val SEPARATION_TOLERANCE = 0.001
+	}
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/Entity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/Entity.kt
deleted file mode 100644
index 675fe658..00000000
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/Entity.kt
+++ /dev/null
@@ -1,155 +0,0 @@
-package ru.dbotthepony.kstarbound.world.entities
-
-import ru.dbotthepony.kommons.math.RGBAColor
-import ru.dbotthepony.kommons.util.AABB
-import ru.dbotthepony.kommons.util.MailboxExecutorService
-import ru.dbotthepony.kommons.vector.Vector2d
-import ru.dbotthepony.kstarbound.client.StarboundClient
-import ru.dbotthepony.kstarbound.world.Chunk
-import ru.dbotthepony.kstarbound.world.World
-import kotlin.concurrent.withLock
-
-abstract class Entity(val world: World<*, *>) {
-	var chunk: Chunk<*, *>? = null
-		set(value) {
-			if (!isSpawned) {
-				throw IllegalStateException("Trying to set chunk this entity belong to before spawning in world")
-			} else if (isRemoved) {
-				throw IllegalStateException("This entity was removed")
-			} else if (value == field) {
-				return
-			}
-
-			val chunkPos = world.chunkFromCell(position)
-
-			if (value != null && chunkPos != value.pos) {
-				throw IllegalStateException("Set proper position before setting chunk this Entity belongs to (expected chunk $chunkPos, got chunk ${value.pos})")
-			}
-
-			val oldChunk = field
-			field = value
-
-			world.lock.withLock {
-				if (oldChunk == null && value != null) {
-					world.orphanedEntities.remove(this)
-					value.addEntity(this)
-				} else if (oldChunk != null && value == null) {
-					world.orphanedEntities.add(this)
-					oldChunk.removeEntity(this)
-				} else if (oldChunk != null && value != null) {
-					value.transferEntity(this, oldChunk)
-				}
-			}
-		}
-
-	var position = Vector2d()
-		set(value) {
-			if (field == value)
-				return
-
-			val old = field
-			field = Vector2d(world.geometry.x.cell(value.x), world.geometry.y.cell(value.y))
-
-			if (isSpawned && !isRemoved) {
-				val oldChunkPos = world.chunkFromCell(old)
-				val newChunkPos = world.chunkFromCell(field)
-
-				if (oldChunkPos != newChunkPos) {
-					chunk = world.chunkMap[newChunkPos]
-				}
-			}
-		}
-
-	abstract val movement: AbstractMovementController
-
-	val mailbox = MailboxExecutorService(world.mailbox.thread)
-
-	/**
-	 * true - whitelist, false - blacklist
-	 */
-	protected var collisionFilterMode = false
-
-	/**
-	 * Whenever is this entity spawned in world ([spawn] called).
-	 * Doesn't mean entity still exists in world, check it with [isRemoved]
-	 */
-	var isSpawned = false
-		private set
-
-	/**
-	 * Whenever is this entity was removed from world ([remove] called).
-	 */
-	var isRemoved = false
-		private set
-
-	open fun spawn() {
-		if (isSpawned)
-			throw IllegalStateException("Already spawned")
-
-		isSpawned = true
-
-		world.mailbox.execute {
-			world.entities.add(this)
-			chunk = world.chunkMap[world.chunkFromCell(position)]
-
-			if (chunk == null) {
-				world.orphanedEntities.add(this)
-			}
-		}
-	}
-
-	open fun remove() {
-		if (isRemoved)
-			throw IllegalStateException("Already removed")
-
-		isRemoved = true
-		mailbox.shutdownNow()
-
-		if (isSpawned) {
-			world.mailbox.execute {
-				world.entities.remove(this)
-				chunk?.removeEntity(this)
-			}
-		}
-	}
-
-	/**
-	 * this function is executed sequentially
-	 */
-	open fun thinkShared() {
-		mailbox.executeQueuedTasks()
-	}
-
-	open fun thinkClient() {
-
-	}
-
-	open fun thinkServer() {
-
-	}
-
-	open fun render(client: StarboundClient = StarboundClient.current()) {
-		val hitboxes = movement.localHitboxes.toList()
-		if (hitboxes.isEmpty()) return
-
-		hitboxes.forEach { it.render(client) }
-
-		world.queryCollisions(
-			hitboxes.stream().map { it.aabb }.reduce(AABB::combine).get().enlarge(2.0, 2.0)
-		).filter(movement::shouldCollideWithBody).forEach { it.poly.render(client, BLOCK_COLLISION_COLOR) }
-	}
-
-	open var maxHealth = 0.0
-	open var health = 0.0
-
-	open fun hurt(amount: Double): Boolean {
-		return false
-	}
-
-	companion object {
-		const val PHYSICS_TICKS_UNTIL_SLEEP = 16
-		val BLOCK_COLLISION_COLOR = RGBAColor(65, 179, 217)
-		const val SEPARATION_STEPS = 3
-		const val SEPARATION_TOLERANCE = 0.001
-	}
-}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityActorMovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityActorMovementController.kt
index 38921534..1a94ea66 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityActorMovementController.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityActorMovementController.kt
@@ -7,7 +7,7 @@ import ru.dbotthepony.kstarbound.defs.player.ActorMovementModifiers
 import ru.dbotthepony.kstarbound.world.Direction
 import ru.dbotthepony.kstarbound.world.World
 
-class EntityActorMovementController(val entity: Entity) : AbstractActorMovementController() {
+class EntityActorMovementController(val entity: DynamicEntity) : AbstractActorMovementController() {
 	override val world: World<*, *> by entity::world
 	override var position: Vector2d by entity::position
 	override var actorMovementParameters: ActorMovementParameters = GlobalDefaults.actorMovementParameters
@@ -63,5 +63,5 @@ class EntityActorMovementController(val entity: Entity) : AbstractActorMovementC
 	override val approachVelocityAngles: MutableList<ApproachVelocityAngleCommand> = ArrayList()
 	override var movingDirection: Direction? = null
 	override var facingDirection: Direction? = null
-	override var anchorEntity: Entity? = null
+	override var anchorEntity: DynamicEntity? = null
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityMovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityMovementController.kt
index 113000a7..49a33479 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityMovementController.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/EntityMovementController.kt
@@ -5,7 +5,7 @@ import ru.dbotthepony.kstarbound.GlobalDefaults
 import ru.dbotthepony.kstarbound.defs.MovementParameters
 import ru.dbotthepony.kstarbound.world.World
 
-class EntityMovementController(val entity: Entity) : AbstractMovementController() {
+class EntityMovementController(val entity: DynamicEntity) : AbstractMovementController() {
 	override val world: World<*, *> by entity::world
 	override var position: Vector2d by entity::position
 	override var movementParameters: MovementParameters = GlobalDefaults.movementParameters
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ItemEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ItemEntity.kt
index 57be5fb9..c6579341 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ItemEntity.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ItemEntity.kt
@@ -1,5 +1,6 @@
 package ru.dbotthepony.kstarbound.world.entities
 
+import com.google.gson.JsonObject
 import ru.dbotthepony.kommons.core.Either
 import ru.dbotthepony.kommons.util.AABB
 import ru.dbotthepony.kommons.vector.Vector2d
@@ -7,9 +8,13 @@ import ru.dbotthepony.kstarbound.defs.item.api.IItemDefinition
 import ru.dbotthepony.kstarbound.world.World
 import ru.dbotthepony.kstarbound.world.physics.Poly
 
-class ItemEntity(world: World<*, *>, val def: IItemDefinition) : Entity(world) {
+class ItemEntity(val def: IItemDefinition) : DynamicEntity("/") {
 	override val movement = EntityMovementController(this)
 
+	override fun defs(): Collection<JsonObject> {
+		return emptyList()
+	}
+
 	init {
 		movement.movementParameters = movement.movementParameters.copy(collisionPoly = Either.left(Poly(AABB.rectangle(Vector2d.ZERO, 0.75, 0.75))))
 	}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PlayerEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PlayerEntity.kt
index 3c74ff30..565122dd 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PlayerEntity.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PlayerEntity.kt
@@ -1,12 +1,20 @@
 package ru.dbotthepony.kstarbound.world.entities
 
+import com.google.gson.JsonObject
 import ru.dbotthepony.kstarbound.Starbound
 import ru.dbotthepony.kstarbound.defs.ActorMovementParameters
 import ru.dbotthepony.kstarbound.world.World
 
-class PlayerEntity(world: World<*, *>) : Entity(world) {
+class PlayerEntity() : DynamicEntity("/") {
 	override val movement = EntityActorMovementController(this)
 
+	override val isApplicableForUnloading: Boolean
+		get() = false
+
+	override fun defs(): Collection<JsonObject> {
+		return emptyList()
+	}
+
 	init {
 		movement.actorMovementParameters = movement.actorMovementParameters.merge(
 			Starbound.gson.fromJson("""
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt
new file mode 100644
index 00000000..1c77c2b6
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt
@@ -0,0 +1,47 @@
+package ru.dbotthepony.kstarbound.world.entities
+
+import ru.dbotthepony.kommons.vector.Vector2d
+import ru.dbotthepony.kommons.vector.Vector2i
+import ru.dbotthepony.kstarbound.world.ChunkPos
+import ru.dbotthepony.kstarbound.world.World
+
+/**
+ * (Hopefully) Static world entities (Plants, Objects, etc), which reside on cell grid
+ */
+abstract class TileEntity(path: String) : AbstractEntity(path) {
+	private var forceChunkRepos = false
+
+	var position = Vector2i()
+		set(value) {
+			val old = field
+
+			if (isSpawned) {
+				field = world.geometry.wrap(value)
+
+				val oldChunkPos = world.geometry.chunkFromCell(old)
+				val newChunkPos = world.geometry.chunkFromCell(field)
+
+				chunkPos = newChunkPos
+
+				if (oldChunkPos != newChunkPos || forceChunkRepos) {
+					chunk = world.chunkMap[newChunkPos]
+					forceChunkRepos = false
+				}
+			} else {
+				field = value
+			}
+		}
+
+	final override var chunkPos: ChunkPos = ChunkPos.ZERO
+		private set
+
+	override fun onSpawn(world: World<*, *>) {
+		world.tileEntities.add(this)
+		forceChunkRepos = true
+		position = position
+	}
+
+	override fun onRemove(world: World<*, *>) {
+		world.tileEntities.remove(this)
+	}
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/WorldObject.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/WorldObject.kt
index 4ea7893d..51ad84a1 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/WorldObject.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/WorldObject.kt
@@ -11,6 +11,8 @@ import ru.dbotthepony.kommons.vector.Vector2i
 import ru.dbotthepony.kstarbound.Registries
 import ru.dbotthepony.kstarbound.Registry
 import ru.dbotthepony.kstarbound.Starbound
+import ru.dbotthepony.kstarbound.client.StarboundClient
+import ru.dbotthepony.kstarbound.client.render.LayeredRenderer
 import ru.dbotthepony.kstarbound.client.world.ClientWorld
 import ru.dbotthepony.kstarbound.defs.Drawable
 import ru.dbotthepony.kstarbound.defs.JsonDriven
@@ -22,14 +24,14 @@ import ru.dbotthepony.kstarbound.json.get
 import ru.dbotthepony.kstarbound.json.set
 import ru.dbotthepony.kstarbound.world.Side
 import ru.dbotthepony.kstarbound.world.LightCalculator
+import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITf
 import ru.dbotthepony.kstarbound.world.World
 import ru.dbotthepony.kstarbound.world.api.TileColor
 import kotlin.properties.Delegates
 
 open class WorldObject(
 	val prototype: Registry.Entry<ObjectDefinition>,
-	val pos: Vector2i,
-) : JsonDriven(prototype.file?.computeDirectory() ?: "/") {
+) : TileEntity(prototype.file?.computeDirectory() ?: "/") {
 	fun deserialize(data: JsonObject) {
 		direction = data.get("direction", directions) { Side.LEFT }
 		orientationIndex = data.get("orientationIndex", -1)
@@ -48,7 +50,7 @@ open class WorldObject(
 	fun serialize(): JsonObject {
 		val into = JsonObject()
 		into["name"] = prototype.key
-		into["tilePosition"] = vectors.toJsonTree(pos)
+		into["tilePosition"] = vectors.toJsonTree(position)
 		into["direction"] = directions.toJsonTree(direction)
 		into["orientationIndex"] = orientationIndex
 		into["interactive"] = interactive
@@ -61,10 +63,6 @@ open class WorldObject(
 		return into
 	}
 
-	val mailbox = MailboxExecutorService()
-	var world: World<*, *> by Delegates.notNull()
-		private set
-
 	//
 	// internal runtime properties
 	//
@@ -83,11 +81,6 @@ open class WorldObject(
 	private var frameTimer = 0.0
 	val flickerPeriod = prototype.value.flickerPeriod?.copy()
 
-	var isRemoved = false
-		private set
-	var isSpawned = false
-		private set
-
 	//
 	// top level properties
 	//
@@ -139,33 +132,12 @@ open class WorldObject(
 		super.invalidate()
 	}
 
-	protected open fun innerSpawn() {}
-	protected open fun innerRemove() {}
-
-	fun spawn(world: World<*, *>) {
-		check(!isSpawned) { "Already spawned in ${this.world}!" }
-		this.world = world
-		isSpawned = true
-		innerSpawn()
-		invalidate()
-	}
-
-	fun remove() {
-		if (isRemoved || !isSpawned) return
-		isRemoved = true
-
-		world.mailbox.execute {
-			check(world.objects.remove(this))
-			innerRemove()
-		}
-	}
-
-	open fun thinkShared() {
-		mailbox.executeQueuedTasks()
+	override fun thinkShared() {
+		super.thinkShared()
 		flickerPeriod?.update(Starbound.TICK_TIME_ADVANCE, world.random)
 	}
 
-	open fun thinkClient() {
+	override fun thinkRemote() {
 		val orientation = orientation
 
 		if (orientation != null) {
@@ -174,10 +146,6 @@ open class WorldObject(
 		}
 	}
 
-	open fun thinkServer() {
-
-	}
-
 	val orientation: ObjectOrientation? get() {
 		return orientations.getOrNull(orientationIndex)
 	}
@@ -193,7 +161,7 @@ open class WorldObject(
 			?: ImmutableMap.of()
 	}
 
-	fun addLights(lightCalculator: LightCalculator, xOffset: Int, yOffset: Int) {
+	override fun addLights(lightCalculator: LightCalculator, xOffset: Int, yOffset: Int) {
 		var color = lightColors[color.lowercase]
 
 		if (color != null) {
@@ -202,7 +170,16 @@ open class WorldObject(
 				color *= sample
 			}
 
-			lightCalculator.addPointLight(pos.x - xOffset, pos.y - yOffset, color)
+			lightCalculator.addPointLight(position.x - xOffset, position.y - yOffset, color)
+		}
+	}
+
+	override fun render(client: StarboundClient, layers: LayeredRenderer) {
+		val layer = layers.getLayer(orientation?.renderLayer ?: return)
+
+		drawables.forEach {
+			val (x, y) = imagePosition
+			it.render(client, layer, position.x.toFloat() + x / PIXELS_IN_STARBOUND_UNITf, position.y.toFloat() + y / PIXELS_IN_STARBOUND_UNITf)
 		}
 	}
 
@@ -216,7 +193,8 @@ open class WorldObject(
 		fun fromJson(content: JsonObject): WorldObject {
 			val prototype = Registries.worldObjects[content["name"]?.asString ?: throw IllegalArgumentException("Missing object name")] ?: throw IllegalArgumentException("No such object defined for '${content["name"]}'")
 			val pos = content.get("tilePosition", vectors) { throw IllegalArgumentException("No tilePosition was present in saved data") }
-			val result = WorldObject(prototype, pos)
+			val result = WorldObject(prototype)
+			result.position = pos
 			result.deserialize(content)
 			return result
 		}