In this post we’re going to have a look at creating a MMO style inventory system using Java. This will later be connected with Unreal Engine to complete the backend-frontend flow, but in meantime we will be testing using Postman.
Bear in mind, this is work in progress, but it’s now hit a checkpoint – it’s also quite large in size so should be broken into chunks.
The video summary of what’s covered in this topic can be found here:
All the server code can be found on Github.
The packages introduced in this post are highlighted below:
Mainly the items
package and character.inventory
package to look at.
What are we implementing in this post?
Item and inventory related functionality, we will have functions which will be able to:
- Create a new item of type (weapon, armour, consumable)
- Spawn an item in a undefined/custom map
- Fetch nearby items based on character location
- Generate inventory for a user (will/can be linked to character creation flow)
- Getting user current inventory
- Picking up an item (by reference)
- Dropping an item from inventory (based on item position)
Implementation structure
There will be two parts to this, one relating to items and another to inventory itself.
Let’s begin with the generic items first. Let’s focus on main concepts introduced:
- Items Controller
- Items Service
- Items Repository
- Items Models
This is standard Controller - Service - Repository
pattern. Let’s start with model and work backwards.
Note I am not planning on going over every file introduced as some are small and self explanatory – feel free to browse the repository. I am looking at going over the more complex parts and just going over the ‘flow’ of it all, as users should be able to adapt this to their needs.
Items model
We first create a Items
model, which is an abstract class.
@Data
@Introspected
@NoArgsConstructor
@BsonDiscriminator
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property= "category")
// define all the serializers that Items can be, weapon, armour, consumable etc.
@JsonSubTypes({
@JsonSubTypes.Type(value = Weapon.class, name = "WEAPON"),
@JsonSubTypes.Type(value = Armour.class, name = "ARMOUR"),
@JsonSubTypes.Type(value = Consumable.class, name = "CONSUMABLE"),
})
public abstract class Item {
@BsonCreator
@JsonCreator
public Item(
@JsonProperty("itemId")
@BsonProperty("itemId") String itemId,
@JsonProperty("itemName")
@BsonProperty("itemName") String itemName,
@JsonProperty("category")
@BsonProperty("category") String category,
@JsonProperty("tags")
@BsonProperty("tags") List<Tag> tags,
@JsonProperty("stacking")
@BsonProperty("stacking") Stacking stacking,
@JsonProperty("value")
@BsonProperty("value") Integer value) {
this.itemId = itemId;
this.itemName = itemName;
this.category = category;
this.tags = tags;
this.stacking = stacking;
this.value = value;
}
String itemId;
String itemName;
String category;
List<Tag> tags;
Stacking stacking;
Integer value;
}
Why abstract? Because each of our items will need a type/category. E.g. we will have weapons, armour, consumables, etc. All these items will have some commonality, such as containing item id
, item name
, value
of the item, etc.
Then you can have your actual item types defined in similar fashion to:
@Data
@NoArgsConstructor
@JsonTypeName("weapon")
@EqualsAndHashCode(callSuper=false)
public class Weapon extends Item {
public Weapon(String itemId, String itemName, List<Tag> tags, Stacking stacking, Integer value) {
super(itemId, itemName, ItemType.WEAPON.getType(), tags, stacking, value);
}
}
Note the above on its own is not super useful – so why did we add it?
Because its very extensible. We included List<Tag> tags
part of items as generic object.
Tag definition is:
@Data
@Introspected
@NoArgsConstructor
public class Tag {
@BsonCreator
@JsonCreator
public Tag(
@JsonProperty("name")
@BsonProperty("name") String name,
@JsonProperty("value")
@BsonProperty("value") String value) {
this.name = name;
this.value = value;
}
// Key value pair
String name;
String value;
}
I.e. its a simple map interface containing key and value pairs.
This is a pretty powerful tool which allows the model to remain extensible without making significant changes in DB.
The tags can contain something like name=armour, value=30
and name=damage, value=100
.
Then you can search for specific tags inside your custom item classes, such as Weapon
class will know to look for a tag with name weapon
. I will dig more into this in future posts, this part is still work in progress.
Items service
The items service will be responsible for all generic items queries. E.g. does item exist, is it dropped, remove it from map etc. It does not provide you with what items the user has in their inventory, that will have its own service layer.
@Singleton
public class ItemService {
@Inject
ItemRepository itemRepository;
public DroppedItem dropItem(String itemId, Location location) {
LocalDateTime now = LocalDateTime.now();
Item foundItem = itemRepository.findByItemId(itemId);
String uuid = UUID.randomUUID().toString(); // generate unique ID for the dropped item
DroppedItem droppedItem = new DroppedItem(uuid, location.getMap(), location, foundItem, now);
return itemRepository.createDroppedItem(droppedItem);
}
public DroppedItem getDroppedItemById(String droppedItemId) {
return itemRepository.findDroppedItemById(droppedItemId);
}
public List<DroppedItem> getItemsInMap(Location location) {
return itemRepository.getItemsNear(location);
}
public void deleteDroppedItem(String droppedItemId) {
itemRepository.deleteDroppedItem(droppedItemId);
}
public Item createItem(Item item) {
return itemRepository.createItem(item);
}
public void clearAllItemData() {
// this is a function for test purposes
itemRepository.deleteAllItemData();
}
}
The service layer handles the main interaction. Most functions are pretty small and self explanatory, so let’s look at the more complex one: dropItem
.
This function is not in itself linked to an inventory
at all. It can be called for example on a monsters death, or when chopping a tree, etc.
Therefore the input is itemId
and location
. With this input we can search the repository to find the Item
object with the itemId
then start creating a new object DroppedItem
.
The DroppedItem
model is:
@Data
@Introspected
@JsonInclude()
@NoArgsConstructor
public class DroppedItem {
@BsonCreator
@JsonCreator
public DroppedItem(
@JsonProperty("droppedItemId")
@BsonProperty("droppedItemId") String droppedItemId,
@JsonProperty("map")
@BsonProperty("map") String map,
@JsonProperty("location")
@BsonProperty("location") Location location,
@JsonProperty("item")
@BsonProperty("item") Item item,
@JsonProperty("updatedAt")
@BsonProperty("updatedAt") LocalDateTime droppedAt
) {
this.droppedItemId = droppedItemId;
this.map = map;
this.location = location;
this.item = item;
this.droppedAt = droppedAt;
}
String droppedItemId;
String map;
Location location;
Item item;
// This is to have timeout for items that are dropped/spawned
LocalDateTime droppedAt;
}
The main part to highlight here is droppedItemId
which should be a unique identifier to symbolize the item.
That’s why in the dropItem
function we do this:
String uuid = UUID.randomUUID().toString(); // generate unique ID for the dropped item
DroppedItem droppedItem = new DroppedItem(uuid, location.getMap(), location, foundItem, now);
The model can then be saved and all of a sudden, we have an item existing on a map!
Another mention should be made for:
public List<DroppedItem> getItemsInMap(Location location) {
return itemRepository.getItemsNear(location);
}
This calls:
public List<DroppedItem> getItemsNear(Location location) {
// filter by map + location of X, Y, Z co-ordinates
return MongoDbQueryHelper.betweenLocation(droppedItemCollection, location, 100);
}
Which in terms calls a helper method (as I believed the function looked a bit complex to be directly in repository layer):
public static <T> List<T> betweenLocation(MongoCollection<T> collection, Location location, Integer threshold) {
Bson mapEq = Filters.eq("map", location.getMap());
Bson xWithinRange = Filters.and(
Filters.gt("location.x", (location.getX() - threshold)),
Filters.lt("location.x", (location.getX() + threshold))
);
Bson yWithinRange = Filters.and(
Filters.gt("location.y", (location.getY() - threshold)),
Filters.lt("location.y", (location.getY() + threshold))
);
return Flowable.fromPublisher(
collection.find(
and(
mapEq,
and(xWithinRange, yWithinRange)
)
)).toList()
.blockingGet();
}
So as you can see this query looks a little complicated – but its doing something relatively simple.
- the DroppedItems model contains a
Location
model Location
model containsString map
,Integer x
,Integer y
andInteger z
- We search where the
map
matches exactly to desired value - We also check that
X
andY
co-ordinates are within a threshold (100 default)
Items Controller
The items controller class is just referencing all the API connections that we will support:
@Secured(SecurityRule.IS_AUTHENTICATED)
@Controller("/v1/items")
public class ItemController {
@Inject
ItemService itemService;
@Post("/create-item")
public Item updatePlayerLocation(Principal principal, @Body GenericInventoryData data) {
// This is a test endpoint!
// you should not be creating items live, they should be handled via migration
return itemService.createItem(data.getItem());
}
@Post("/spawn-item")
public DroppedItem spawnItem(Principal principal, @Body GenericInventoryData inventoryData) {
// TODO: this will be changed to a loot service. e.g. when mob is killed, spawn random items
return itemService.dropItem(inventoryData.getItemId(), inventoryData.getLocation());
}
@Get("/dropped")
public List<DroppedItem> getDroppedItems(@QueryValue String map, @QueryValue Integer x, @QueryValue Integer y) {
Location location = new Location(map, x, y, null);
return itemService.getItemsInMap(location);
}
@Post("/clear-data")
public void clearAll() {
// this is a test endpoint for clearing all DB data relating to items
itemService.clearAllItemData();
}
}
We have the following requests support so far:
@Post("/create-item") @Post("/spawn-item") @Get("/dropped") @Post("/clear-data")
- Create item endpoint will take in an
Item
as body request and create that item - Spawn item will create a
DroppedItem
object, i.e. dropping it on map - Get dropped request will fetch dropped item objects within a range
- Clear data endpoint is a test endpoint for clearing our repositories
Now let’s get into the Inventory
data.
Inventory design
Let’s begin with the inventory model:
@Data
@Introspected
@JsonInclude()
@NoArgsConstructor
public class Inventory {
@BsonCreator
@JsonCreator
public Inventory(
@JsonProperty("characterName")
@BsonProperty("characterName") String characterName,
@JsonProperty("characterItems")
@BsonProperty("characterItems") List<CharacterItem> characterItems,
@JsonProperty("gold")
@BsonProperty("gold") Integer gold,
@JsonProperty("maxSize")
@BsonProperty("maxSize") Location2D maxSize
) {
this.characterName = characterName;
this.characterItems = characterItems;
this.gold = gold;
this.maxSize = maxSize;
}
String characterName;
List<CharacterItem> characterItems;
Integer gold;
Location2D maxSize;
}
This contains:
- characterName
- characterItems
- gold
- maxSize
Character name is the identifier for the inventory – i.e. who it belongs to.
Character Items refer to all the objects inside the inventory. The inner model is:
@Data
@Introspected
@NoArgsConstructor
public class CharacterItem {
@BsonCreator
@JsonCreator
public CharacterItem(
@JsonProperty("characterName")
@BsonProperty("characterName") String characterName,
@JsonProperty("item")
@BsonProperty("item") Item item,
@JsonProperty("location")
@BsonProperty("location") Location2D location) {
this.characterName = characterName;
this.item = item;
this.location = location;
}
String characterName;
Item item;
// position can be anything you like, 1d, 2d ints, string..
Location2D location;
}
i.e. the character item
has the fields:
- character name
- item reference
- location (2d, x and y co-ordinate)
The item can just be item ID, and potentially I will modify this soon.
Location is x and y co-ordinate, but it can be anything, e.g. Integer, String, etc.
In this example, imagine a 2D inventory array, 10×10 slots. That’s what we’re implementing here as its easy to work with.
So the inventory contains a List<CharacterItem>
.
Let’s look at the service layer to see how we use it.
Inventory service
Here’s the code in inventory service so far:
@Slf4j
@Singleton
public class InventoryService {
@Inject
InventoryRepository inventoryRepository;
@Inject
ItemService itemService;
public Inventory pickupItem(String characterName, String droppedItemId) throws InventoryException {
// Could add additional validations.
// For example add unique ID to player items and match it with dropped ID
// There can be occasions where when laggy
// you could add item more than once / to multiple users
DroppedItem droppedItem = itemService.getDroppedItemById(droppedItemId);
Item item = droppedItem.getItem();
Inventory inventory = inventoryRepository.getCharacterInventory(characterName);
// check for example if inventory is full
List<CharacterItem> items = inventory.getCharacterItems();
Location2D position = getNextAvailableSlot(inventory);
CharacterItem newCharacterItem = new CharacterItem(characterName, item, position);
items.add(newCharacterItem);
// delete the dropped item first (this is a blocking get) to prevent duplication
itemService.deleteDroppedItem(droppedItemId);
inventoryRepository.updateInventoryItems(characterName, items);
return inventory;
}
public DroppedItem dropItem(String characterName, Location2D inventoryLocation, Location location)
throws InventoryException {
Inventory inventory = inventoryRepository.getCharacterInventory(characterName);
CharacterItem characterItem = getItemAtLocation(inventoryLocation, inventory);
if (characterItem == null) {
return null;
}
List<CharacterItem> itemsList = inventory.getCharacterItems();
itemsList.remove(characterItem);
inventoryRepository.updateInventoryItems(characterName, itemsList);
// TODO: if dropItem fails, we need to revert the removal of item from inventory.
return itemService.dropItem(characterItem.getItem().getItemId(), location);
}
public Inventory getInventory(String characterName) {
return inventoryRepository.getCharacterInventory(characterName);
}
public void sellItem() {
// TODO: later
}
public void equipItem() {
// validate item and target
// TODO: later
}
public void pickupGold() {
// TODO: later
}
public Inventory createInventoryForNewCharacter(String characterName) {
Inventory inventory = new Inventory();
inventory.setCharacterName(characterName);
inventory.setCharacterItems(new ArrayList<>());
inventory.setGold(0);
inventory.setMaxSize(new Location2D(10, 10));
return inventoryRepository.insert(inventory);
}
private Location2D getNextAvailableSlot(Inventory inventory) {
// Implement this as per your requirement, based on position for example.
Location2D maxSize = inventory.getMaxSize();
List<CharacterItem> items = inventory.getCharacterItems();
int[][] invArr = new int[maxSize.getX()][maxSize.getY()];
items.forEach(i -> {
Location2D loc = i.getLocation();
invArr[loc.getX()][loc.getY()] = 1;
});
for (int x = 0; x < maxSize.getX(); x++) {
for (int y = 0; y < maxSize.getY(); y++) {
if (invArr[x][y] != 1) {
return new Location2D(x,y);
}
}
}
return null;
}
public void clearAllDataForCharacter(String characterName) {
inventoryRepository.deleteAllInventoryDataForCharacter(characterName);
}
private CharacterItem getItemAtLocation(Location2D location, Inventory inventory) {
List<CharacterItem> items = inventory.getCharacterItems();
Optional<CharacterItem> item = items.stream().filter(i -> i.getLocation().equals(location)).findFirst();
if (item.isPresent()) {
return item.get();
} else {
log.warn("item was not found in the inventory");
return null;
}
}
}
Let’s describe what we have here as there appears a lot of things going on
clearAllDataForCharacter
-> This is a test function for clearing all inventory for a given user, so don’t worry about this, can be removed.
createInventoryForNewCharacter
-> This function creates/prepares default inventory for a new character. It should be called upon character creation.
getInventory
-> This function simply fetches the inventory object from the repository by character name.
Now to the more complicated functions:
public Inventory pickupItem(String characterName, String droppedItemId)
This function takes the character name
and the dropped item id
.
- The first thing we want to do is get the
DroppedItem
object by using the ID to locate it – make sure it still exists.
- Next, we load the character inventory. Load it using the character name.
- Now pre-load the character items and identify the next available inventory slot. We do this with the use of
Location2D
onCharacterItem
object and themaxSize
on theInventory
object.
- We preload a
CharacterItem
object with the newItem
- Delete the
DroppedItem
object, preventing anyone else picking it up too.
- Insert/update the character inventory with the updated items.
public DroppedItem dropItem(String characterName, Location2D inventoryLocation, Location location)
- This function takes character name and the location of the slot that you want to drop.
- Then you load up the inventory from database, by character name.
- Identify the item at location (to drop)
- Remove the item from the inventory list
- Use the
ItemService
todrop
the item (that we covered above)
This covers some of the more complex functionality in this chapter, so let’s go ahead with testing it live.
Testing the services
We’re going to use Postman to test the application.
We introduced authentication in one of chapters in the past covered here – we will be utilizing this here.
So first you want to login/register and get yourself the access token
– also referred to as Bearer token
.
Now that you have authentication (logged in), we can create an item – this is usually handled in migrations. We can however make this endpoint only accessible by admins (e.g. having admin panel).
Create item test
Url is: POST
http://localhost:8081/v1/items/create-item
.
The additional headers required are:
Content-Type : application/json
Accept : application/json
The body is: (JSON type)
{
"item": {
"itemId": "123",
"itemName": "cool armour",
"category": "ARMOUR",
"tags": [
{
"name": "defence",
"value": "30"
}
],
"value": 1000,
"stacking": {
"canStack": false,
"maxStacks": 1
}
}
}
This is a serialized form of the Item
object.
Here we’re creating an item with ID 123
(we could make this be given random ID at creation)
We can give the item a name, in this case cool armour
.
Category then has to be defined, since we’re making armour, its set to ARMOUR
.
The tags are not in use yet, but give you an idea of how it will be dynamic and can hold the properties we desire.
Value will refer to shop value of the item.
Stacking options are also not in use yet, but will be used to assist with stacking the items in inventory (up to max stacks allowed).
When this endpoint is successful, it responds with the item that you’ve created (should match with the input you set).
Spawn item test
Url is: POST http://localhost:8081/v1/items/spawn-item
The additional headers required are:
Content-Type : application/json
Accept : application/json
The body is: (JSON type)
{
"itemId": "123",
"location": {
"map": "map1",
"x": 340,
"y": 340,
"z": 123
}
}
Here we just need to specify the item id (we created previous item using ID 123
so we can use that.
Location refers to the map, x, y, z co-ordinates
.
Here the response contains DroppedItem
object. This contains the new unique identifier (droppedItemId
) for the object. The map and location of the object and the item itself.
Note that there is some data duplication such as on map
.
This is to consider in near future – in reality nested objects are slower to search on database and we have location
as a nested object. We could add an index to this, but direct records have significant speed advantages when querying them. So potentially I would have to restructure this, but for now I like the refactored Location
object.
Get nearby items test
Example URL is: GET http://localhost:8081/v1/items/dropped?map=map1&x=100&y=100
The additional headers required are:
Content-Type : application/json
Accept : application/json
The url encoded parameters are:
map
– e.g.map1
x
– e.g.100
y
– e.g.100
The results:
Experiment with dropping multiple items and changing the input parameters for getting dropped items.
Generate inventory test
The URL is: POST http://localhost:8081/v1/inventory/generate-inventory
The additional headers required are:
Content-Type : application/json
Accept : application/json
The post body is:
{
"characterName": "character1"
}
Here we specify the character name for whom we generate base inventory (initialize the object and save to db).
Pickup item test
The URL is: POST http://localhost:8081/v1/inventory/pickup
The additional headers required are:
Content-Type : application/json
Accept : application/json
characterName : character1
Don’t forget to add the characterName
header here – put the character name to the same one as created in step above.
The post body is:
{
"droppedItemId": "b9d40211-e01c-4c41-b8c9-fca3a4ec9653"
}
Change the ID
to the one generated in SpawnItem
or Get nearby items
.
We don’t do location validation here yet (we can potentially add that later).
Get inventory test
Now that you’ve ‘picked up’ and item, let’s get the inventory and make sure the item is present.
The URL is: POST http://localhost:8081/v1/inventory/
The additional headers required are:
Content-Type : application/json
Accept : application/json
characterName : character1
There is no body or url params here
In this example I created and picked up a second item too – so inventory contains cool armour
and cool weapon
.
Drop item test
Now that we have an item in our inventory, we can go ahead and try drop it.
The URL is: POST http://localhost:8081/v1/inventory/drop
The additional headers required are:
Content-Type : application/json
Accept : application/json
characterName : character1
The post body is:
{
"characterName": "character1",
"itemInventoryLocation": {
"x": 0,
"y": 0
},
"location": {
"map": "map1",
"x": 123,
"y": 123,
"z": 123
}
}
Here we dropped an item that was on co-ordinates (0, 0)
i.e. the first slot in the inventory.
The item was dropped on map map
in 3rd co-ordinates (123, 123 123)
.
Summing up
That’s basically it from this chapter! As you can see there’s still a bunch of work that would be required for a complete inventory.
For example, we still have no methods for equipping items. Our weapon, armour, consumable classes don’t have their custom attributes defined yet. However this is at a checkpoint where we could potentially integrate it with UE and have objects being created, spawned on map, saved to an inventory list etc.
The inventory module is actually relatively complex, but at same time usually sticks to generic patterns which makes it quite manageable.
Good luck with your projects!
Pingback: 14. MMO/RPG Inventory in Unreal Engine & server – The web tech journal
Pingback: 17. Java MMO/RPG inventory implementation – equipping items – The web tech journal