Building an indie MMORPG – part 7: Login, database persistence, monsters, items and obstacles
October 3, 2024
12 min read
Implementation of registration and login system with a welcome screen. Game state persistence in MongoDB. Introduction of monsters with basic AI, item corpses, and tile-blocking obstacles like trees and rocks.

Introduction
Hey! As you might have noticed, I haven't published anything on the blog for a while. First, it was the holiday season, and second, I spent a lot of time working on new features in the game. I decided to split the description of everything I’ve done into two posts. In this post, I’ll talk about the implementation of the login and registration system, saving the game state to the database, as well as introducing new elements like monsters, items, and obstacles. In the next post, I’ll focus on the map editor I built, player skills, the ability to move items, and the option for longer marches. Additionally, I invested in a better server!
As usual, I’ll start by showing a gameplay video where you can see how everything currently looks (the map editor video will appear in the next post) and presenting the start screen with the welcome graphic.

Registration and login
I'm sure all of you are familiar with the registration and login process. For me, the biggest challenge was implementing the assignment of identifiers. I didn’t want to use UUID, as it takes up a lot of space, and I wanted to keep the messages sent via WebSocket as lightweight as possible. From the start, I assumed there would be a maximum of 500 players on the server and around 3-4 thousand monsters, so I decided to use identifiers of the Short
type. In the future, I’ll just need to handle ID recycling to avoid exhausting the pool when respawning new monsters, or I could simply switch to the Int
or Long
type.
Assigning IDs
The information about the last assigned player ID is stored in a document in the MongoDB database:
@ToString
@Document(collection = "ids_sequences")
@AllArgsConstructor
@Getter
public class IdsSequenceDocument {
public static final String PLAYER_SEQUENCE_ID = "PLAYER";
public static final String ITEM_SEQUENCE_ID = "ITEM";
@Id
private final String id;
private short sequence;
public static IdsSequenceDocument init(String sequenceId) {
return new IdsSequenceDocument(sequenceId, (short) 0);
}
public IdsSequenceDocument updateSequence(AtomicInteger sequence) {
this.sequence = sequence.shortValue();
return this;
}
}
When the server starts, it first checks if the above document exists in the database. If it doesn't, we create it for each type of ID with initial values set to 0, and then we load the last ID values into the IdsSequencer
object.
public interface IdsSequencer {
short MAX_PLAYER_ID = 500;
short nextPlayerId();
short nextMonsterId();
short nextItemId();
static boolean isPlayerId(short id) {
return id <= MAX_PLAYER_ID;
}
}
...which is implemented as follows:
@Getter
@ToString
public class DbIdsSequencer implements IdsSequencer {
private final AtomicInteger playerIdSequence;
private final AtomicInteger monsterIdSequence;
private final AtomicInteger itemIdSequence;
public DbIdsSequencer(short initialPlayerId, short initialItemId) {
this.playerIdSequence = new AtomicInteger(initialPlayerId);
this.monsterIdSequence = new AtomicInteger(IdsSequencer.MAX_PLAYER_ID);
this.itemIdSequence = new AtomicInteger(initialItemId);
}
@Override
public short nextPlayerId() {
return (short) playerIdSequence.incrementAndGet();
}
@Override
public short nextMonsterId() {
return (short) monsterIdSequence.incrementAndGet();
}
@Override
public short nextItemId() {
return (short) itemIdSequence.incrementAndGet();
}
}
The DbIdsSequencer
object is accessed not only by the main game thread but also by threads handling REST controllers and the database, so I had to use the AtomicInteger
class to ensure thread safety in a multi-threaded environment.
Registration
In the registration process, the requirement was to save the player and update the ID sequence in a single transaction. We can't allow a situation where the player document is saved but the identifier sequence isn't. Fortunately, MongoDB has supported transactions for some time now, which helps prevent these kinds of issues. However, it does require a bit of configuration:
@Configuration
public class ReactiveMongoConfig extends AbstractReactiveMongoConfiguration {
@Value("${spring.data.mongodb.uri}")
private String url;
@Bean
ReactiveMongoTransactionManager transactionManager(ReactiveMongoDatabaseFactory dbFactory) {
return new ReactiveMongoTransactionManager(dbFactory);
}
@Bean
@Override
public MongoClient reactiveMongoClient() {
return MongoClients.create(url);
}
@Override
protected String getDatabaseName() {
return "evoloria";
}
}
Now, it's enough to add the @Transactional
annotation above the service method, which allows saving the player and updating the ID sequence in an atomic way:
@Transactional
public Mono<RegisterSuccessfulDto> registerPlayer(String username, String password, String confirmPassword) {
if (!password.equals(confirmPassword)) {
return Mono.error(GameError.PASSWORD_NO_MATCH.toException());
}
return playerRepository.findByUsername(username)
.flatMap(existingPlayerDocument -> Mono.<PlayerDocument>error(GameError.USERNAME_EXISTS.toException()))
.switchIfEmpty(Mono.defer(() -> {
final var encodedPassword = passwordEncoder.encode(password);
return playerRepository.save(createPlayerDocument(username, encodedPassword, startingPlayerPosition))
.flatMap(savedPlayer -> idsSequenceRepository.findPlayerSequence()
.flatMap(sequenceDoc -> idsSequenceRepository.save(sequenceDoc.updateSequence(idsSequencer.getPlayerIdSequence())))
.thenReturn(savedPlayer));
}))
.map(it -> new RegisterSuccessfulDto(it.getId()));
}
Login
The login process is much simpler. It checks whether a player exists for the given username and whether the provided password is correct. If everything matches, a JWT is generated and returned. The client receives the JWT from the REST service and attaches it when establishing a WebSocket connection, where the token is then validated. Below is the login method code:
public Mono<JwtDto> loginPlayer(String username, String password) {
return playerRepository.findByUsername(username)
.flatMap(playerDocument -> {
if (checkPassword(playerDocument, password)) {
String token = jwtService.generateToken(String.valueOf(playerDocument.getId()));
return Mono.just(new JwtDto(token));
} else {
return Mono.error(GameError.INVALID_CREDENTIALS.toException());
}
})
.switchIfEmpty(Mono.error(GameError.PLAYER_NOT_FOUND.toException()));
}
Saving game state
Saving the game state is a significant improvement. Players don’t lose progress, and after logging back in, they appear in the location where they last logged out. Currently, the strategy only involves saving player objects. The state of the map is not persisted, which means that after a server restart, the map returns to its original state. For example, if a valuable sword was lying on a tile, it will be lost. Similarly, all monsters will respawn anew. Soon, I plan to introduce an inventory system, which will also require saving the highest item identifier among those players are carrying.
The game state is saved in two cases:
- When a player logs out – their state is saved immediately
- Every 10 seconds – an automatic save of all active players occurs
Below is the code for the class responsible for these tasks:
@RequiredArgsConstructor
public class GameStatePersisterJob {
private final GameTasksScheduler gameTasksScheduler;
private final GameStatePersister gameStatePersister;
private final GameState gameState;
public void run() {
schedule();
}
public void persistPlayerById(short playerId) {
gameState.getPlayerById(playerId)
.ifPresent(it -> gameStatePersister.persistPlayer(it.toSnapshot()));
}
private void schedule() {
gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofSeconds(10), this::saveActivePlayers));
}
private void saveActivePlayers() {
schedule();
gameStatePersister.persistPlayers(gameState.getAllPlayers()
.stream()
.map(Player::toSnapshot)
.collect(Collectors.toList()));
}
}
The run
method is executed at the very start, while persistPlayerById
is called when a player logs out of the game.
Monsters
Monsters in the game appear through objects called MonsterSpawn
. Each of these objects is initialized at server startup based on the information that a specific monster should appear on a given tile at a set time:
@RequiredArgsConstructor
@Builder
public class MonsterSpawn {
private final MonsterPrefabricate prefabricate;
private final Duration baseSpawnTime;
private final Position position;
private final GameTasksScheduler gameTasksScheduler;
private final GameState gameState;
private final ClientsNotificator clientsNotificator;
private final SpectatorsCalculator spectatorsCalculator;
private final IdsSequencer idsSequencer;
private final MonsterAIActionProvider monsterAIActionProvider;
public void scheduleSpawn() {
gameTasksScheduler.schedule(new GameTasksScheduler.Task(baseSpawnTime, this::spawn));
}
private void spawn() {
final var newMonster = new Monster(
idsSequencer.nextMonsterId(),
prefabricate.getTypeId(),
prefabricate.getMonsterName(),
position,
prefabricate.getAbilities(),
this,
prefabricate.getMonsterBehaviorType());
gameState.addMonster(newMonster);
clientsNotificator.showNewCreatureSpawned(spectatorsCalculator.calculateForPos(position), newMonster.toCreatureView());
gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofSeconds(1), () -> new MonsterAI(gameState, monsterAIActionProvider, newMonster.getId()).run()));
}
}
The code works in such a way that we first schedule the monster's appearance after a specified time (the variable baseSpawnTime
). When the monster is ready to spawn, we create its instance with the appropriate parameters, add it to the game state, and notify observers in the game. Finally, after a second, we activate the monster's artificial intelligence (AI).
Attentive readers might wonder how the monster is respawned after death. I solved this by having the Monster
class maintain a reference to its spawn, and at the very end of its life, it calls the scheduleSpawn
method. This way, the monsters reincarnate infinitely :).
Monster AI
As I mentioned above, after 1 second of the monster's appearance, we activate its artificial intelligence (AI):
@RequiredArgsConstructor
public class MonsterAI {
private final GameState gameState;
private final MonsterAIActionProvider monsterAIActionProvider;
private final short monsterId;
public void run() {
gameState.getMonsterById(monsterId)
.ifPresent(it -> monsterAIActionProvider.provide(it.getBehaviorType()).accept(it, this::run));
}
}
For variety, I have introduced two types of monster behaviors so far. They can either display classic aggression, meaning they chase and attack the player, or they can be friendly, ignoring the player but offering certain bonuses when "used." Below is the code for the provider that delivers the specific behavior implementation depending on the type of monster, along with the code for each of these implementations:
public class MonsterAIActionProvider {
private final Map<MonsterBehaviorType, MonsterAIAction> providers;
@Builder
public MonsterAIActionProvider(MonsterAIAction attackingMonsterAIAction, MonsterAIAction friendlyMonsterAIAction) {
providers = Map.of(
MonsterBehaviorType.CLASSIC_AGGRESSIVE, attackingMonsterAIAction,
MonsterBehaviorType.FRIENDLY, friendlyMonsterAIAction
);
}
MonsterAIAction provide(MonsterBehaviorType monsterBehaviorType) {
return providers.get(monsterBehaviorType);
}
}
@RequiredArgsConstructor
public class AttackingMonsterAIAction implements MonsterAIAction {
private final GameState gameState;
private final GameTasksScheduler gameTasksScheduler;
private final WalkManager walkManager;
private final FightManager fightManager;
private final PathFinder pathFinder;
@Override
public void accept(Monster monster, Runnable runnable) {
if (monster.hasTarget()) {
gameState.getCreatureById(monster.getTargetId())
.ifPresentOrElse(
target -> {
if (monster.getPosition().isNextTo(target.getPosition())) {
if (monster.isReadyToAttack()) {
this.fightManager.startAttack(monster, target);
}
gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofMillis(200), runnable));
} else {
pathFinder.findNextDirection(monster.getPosition(), target.getPosition())
.ifPresentOrElse(
direction -> walkManager.monsterWalk(monster.getId(), direction, runnable),
() -> {
monster.clearTarget();
gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofMillis(200), runnable));
}
);
}
},
() -> {
monster.clearTarget();
gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofMillis(200), runnable));
}
);
} else {
pathFinder.findNearestPlayer(monster.getPosition())
.ifPresentOrElse(target -> {
fightManager.setNewTarget(monster, target);
runnable.run();
},
() -> walkManager.monsterWalk(monster.getId(), Direction.random(),
() -> gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofMillis(new Random().nextInt(500, 1000)), runnable)))
);
}
}
}
@RequiredArgsConstructor
public class FriendlyMonsterAIAction implements MonsterAIAction {
private final WalkManager walkManager;
private final GameTasksScheduler gameTasksScheduler;
@Override
public void accept(Monster monster, Runnable runnable) {
walkManager.monsterWalk(monster.getId(), Direction.random(),
() -> gameTasksScheduler.schedule(new GameTasksScheduler.Task(Duration.ofMillis(new Random().nextInt(500, 1000)), runnable)));
}
}
I will talk about the algorithm for the monster's pathfinding in the next post, as it is related to planning long marches for the player.
Items
For now, the game features only one item - corpses, which appear after a player or monster dies (I haven't created dedicated snake corpses yet, so a human body appears instead). Soon, I will add swords, armor, shields, etc. The current implementation of the base class for items, Item
, is very simple:
public abstract class Item extends IdentifiableObject {
public Item(short id, byte typeId, Position position) {
super(id, typeId, position);
}
public ItemView toVisibleObject() {
return new ItemView(getId(), getTypeId(), getPosition().toVisibleObject());
}
}
The specific implementation for the corpse looks like this:
public class Corpse extends Item {
public Corpse(short id, Position position) {
super(id, ItemTypeIds.PLAYER_CORPSE, position);
}
}
The id
field ensures the uniqueness of each item, while typeId
defines its type - for example, two swords may be of the same type, but they are unique in the context of the game.
Items on a given tile are stored in a stack, which is logical since the most recently placed item on the field should be on top. Below, I present the representation of items and the operations performed on them:
// Field.java class
private final Stack<Item> items;
// other fields and methods
public boolean hasNoItems() {
return this.items.empty();
}
public Item popFirstItem() {
return this.items.pop();
}
public PushItemEffect calculatePushItemEffect() {
if (tile == Tile.WATER) {
return PushItemEffect.SINK;
}
return PushItemEffect.PLACE;
}
public void pushItem(Item item) {
this.items.push(item);
}
A few more words about the calculatePushItemEffect
method. The current logic assumes that if an item is placed in water, it sinks, meaning it simply disappears from the game.
A corpse appears in the game when a player or monster is defeated. This process is handled by a simple method in the abstract class Creature
, which is inherited by both the player and monster classes:
public Corpse createCorpse(IdsSequencer idsSequencer) {
return new Corpse(idsSequencer.nextItemId(), getPosition());
}
Obstacles
Lastly, let's discuss obstacles. This is fairly straightforward. Obstacles include various types of trees, bushes, boulders, walls, etc. - objects that block the tile for both players and monsters, and also prevent items from being placed on that tile:
@RequiredArgsConstructor
@Getter
public enum Obstacle {
TREE_1((byte) 0x00, "Big tree 1"),
TREE_2((byte) 0x01, "Big tree 2"),
TREE_3((byte) 0x02, "Big tree 3"),
TREE_4((byte) 0x03, "Big tree 4"),
TREE_5_BIG((byte) 0x04, "Big tree 5"),
TREE_6_BIG((byte) 0x05, "Big tree 6"),
PALM_1_BIG((byte) 0x06, "Big palm 1"),
PALM_2_BIG((byte) 0x07, "Big palm 2"),
PALM_3_BIG((byte) 0x08, "Big palm 3"),
PALM_4_BIG((byte) 0x09, "Big palm 4"),
STONE_1((byte) 0x10, "Stone 1"),
STONE_2((byte) 0x11, "Stone 2"),
STONE_3((byte) 0x12, "Stone 3"),
STONE_4((byte) 0x13, "Stone 4"),
STONE_5((byte) 0x14, "Stone 5"),
STONE_6((byte) 0x15, "Stone 6"),
STONE_7_BIG_BL((byte) 0x16, "Big stone 7"),
STONE_7_BIG_BR((byte) 0x17, "Big stone 7"),
STONE_7_BIG_TL((byte) 0x18, "Big stone 7"),
STONE_7_BIG_TR((byte) 0x19, "Big stone 7")
;
private final byte typeId;
private final String obstacleName;
private static final Map<Byte, Obstacle> OBSTACLES_MAP = Stream.of(values())
.collect(Collectors.toMap(Obstacle::getTypeId, it -> it));
public static Obstacle fromTypeId(byte typeId) {
return OBSTACLES_MAP.get(typeId);
}
public byte toVisibleObject() {
return this.typeId;
}
}
An obstacle is not movable and does not need to be unique in the context of the game, so it only contains information about its type and name. It’s also worth noting that there are larger obstacles that take up multiple tiles, such as STONE_7
.
Summary
Today, we once again covered a lot of new things. Initially, I planned to include all the updates in one post, but I realized that wasn't the best idea since there's still more to describe, which would significantly lengthen the entry. We are slowly approaching the point where something substantial will start happening in the game. I still need to implement the inventory system, add a few more monsters, and plan the first 2-3 quests. We’ll see how time goes, but my goal is to have all this done by the end of the year. Cheers!