13. MMO Inventory using Java and MongoDB – Simple Steps

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:

YouTube covering server implementation for inventory system

All the server code can be found on Github.

The packages introduced in this post are highlighted below:

Packages introduced for items

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 contains String map, Integer x, Integer y and Integer z
  • We search where the map matches exactly to desired value
  • We also check that X and Y 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 on CharacterItem object and the maxSize on the Inventory object.
  • We preload a CharacterItem object with the new Item
  • 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 to drop 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.

Get the access token using login endpoint

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).

Create item endpoint test

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.

Response of spawn item

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:

Get nearby items returns array of DroppedItems

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"
}
Result for generating inventory

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).

Testing pickup endpoint

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

Get inventory test

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
    } 
}
Drop item test

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!

2 Comments

Comments are closed