Michał Kmiecik

Michał Kmiecik

Poznań, Polska

I’m a full-time dev building indie startups after hours. I’ve just started making my first small profits — and I’m sharing everything I learn along the way 👇

See behind the scenes ↓

I recently made my first few $ from indie projects. Follow the journey as I build more — and try to make it sustainable.

Building an indie MMORPG – part 5: Smooth movement, player rotation & dynamic map loading

July 25, 2024

14 min read

Fluid walking and rotation, real-time updates for nearby players, dynamic map chunk loading and cleanup, plus key backend threading and serialization improvements.

Early development screenshot of a 2D MMORPG game I started building

Introduction

We are slowly approaching the point where I took a break from writing the game and started writing posts. Today, I will present player rotation, smooth movement (previously our player stopped for a split second on each tile), dynamic map loading during player movement (previously we just loaded one piece and beyond it was a black field), and most importantly, notifying other observers about visible actions happening to a given player.

To spark your imagination, let's start with an introductory video showcasing the effects of everything I just mentioned:

I know it's localhost, but you can hardly see any delays, which made me very happy at that moment. At the beginning of the video, you can notice that with Player 1 I had to move to another tile first because the game currently does not allow two players to occupy the same tile.

A few small new features include displaying the nickname, a health bar (currently not functional), and sand tiles.

Before we discuss the new functionalities, I would like to talk about the backend improvements related to easier command serialization, proper thread management, and the expansion of the frontend for new mechanisms.

Backend improvements

Serializer

At some point, I had to serialize more and more commands that I sent to the frontend. The code started to get messy, and I had more and more classes responsible for serializing various things. I noticed that most of the code was duplicated since all commands consisted of similar elements, such as player id, coordinates, or direction of standing. So, I decided to prepare a single class that could independently build commands in an easy way. In the end, I came up with something like this:

@Slf4j
public class CommandSerializer {

    private final List<Processor> processors;

    public CommandSerializer(byte commandCode) {
        this.processors = new ArrayList<>();
        this.processors.add(dos -> dos.writeByte(commandCode));
    }

    public CommandSerializer writeShort(short value) {
        processors.add(dos -> dos.writeShort(value));
        return this;
    }

    public CommandSerializer writeStandDirection(Direction direction) {
        processors.add(new DirectionProcessor(direction));
        return this;
    }

    public CommandSerializer writeCharacter(CharacterView characterView) {
        processors.add(new CharacterProcessor(characterView));
        return this;
    }

    public CommandSerializer writePosition(PositionView positionView) {
        processors.add(dos -> {
            dos.writeShort(positionView.x());
            dos.writeShort(positionView.y());
        });
        return this;
    }

    public CommandSerializer writeCustom(Processor processor) {
        processors.add(processor);
        return this;
    }

    public ByteBuffer serialize() {
        final var baos = new ByteArrayOutputStream();
        final var dos = new DataOutputStream(baos);

        processors.forEach(it -> {
            try {
                it.process(dos);
            } catch (IOException e) {
                log.error("Cannot serialize", e);
                throw new RuntimeException("Cannot continue the game", e);
            }
        });

        try {
            dos.flush();
        } catch (IOException e) {
            log.error("Cannot serialize", e);
            throw new RuntimeException("Cannot continue the game", e);
        }

        return ByteBuffer.wrap(baos.toByteArray());
    }

    @FunctionalInterface
    public interface Processor {

        void process(DataOutputStream dataOutputStream) throws IOException;

    }
}

Using the above class to build a command for starting a walk to the left:

final var command = new CommandSerializer(ClientCommandTypes.SHOW_START_WALK_LEFT)
                .writeShort(characterView.walkTimeInMillis())
                .writePosition(currentPos)
                .serialize();

Threads

So far, the entire server was running on a few threads defined by the framework. Unfortunately, we cannot leave it like this because our game logic needs to run sequentially, that is, on a single thread. This is not an issue because the logical operations are extremely fast. You should know that I thought about this for two whole days and was considering whether the logic, for greater efficiency, could also run on multiple threads. I analyzed many scenarios, such as when two players want to enter the same tile at the same time. In my mind, I was placing locks on various objects, but I ultimately concluded that the game logic must run on a single thread. Analyzing the public code of another similar game that has had many players for a long time confirmed this for me.

Of course, I see room for optimization, such as introducing a separate thread for each floor since players practically have no interaction between them, or dividing the game into independent areas. At this moment, I don't know how the game will behave with a larger number of players and whether there's even a need for optimization. Another possibility is performing more complex calculations (such as the damage calculation algorithm) also in separate threads.

Logic is not everything. All other actions, such as receiving messages from clients, serialization and deserialization, sending messages out, task scheduling, and in the future, communication with the database, occur in several separate threads, keeping the main logic thread free from any longer operations. I must also mention that to maintain the order of events when sending them to each client, user sessions have their dedicated threads (essentially their private event streams).

Let's show some code. First, the declaration of the main logic thread:

    private final Scheduler GAME_SCHEDULER = Schedulers.newSingle("game-logic-single-scheduler");

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        final var username = session.getHandshakeInfo().getUri().getQuery().split("username=")[1];
        final var playerId = idsGenerator.playerId();
        return Mono.just(new PlayerData(username, playerId))
                .subscribeOn(GAME_SCHEDULER)
                .doOnNext(it -> {
                    log.info("New client connected. Id: {}, Username: {}", playerId, username);
                    sessionManager.addSession(it.id(), session);
                    game.connectPlayer(it.id(), it.username());
                })
                .thenMany(session.receive()
                        .map(WebSocketMessage::getPayload)
                        .map(payload -> gameRouter.route(payload, playerId))
                        .publishOn(GAME_SCHEDULER)
                        .doOnNext(Runnable::run)
                        .doOnError(e -> log.error("Handler doOnError", e))
                        .doFinally(signalType -> {
                            game.disconnectPlayer(playerId);
                            sessionManager.removeSession(playerId);
                            log.info("Client disconnected. Id: {}, Username: {}", playerId, username);
                        }))
                .then();
    }

As you can see, we specify that the logic for connecting a player to the game and all other actions take place on the dedicated thread. It's also worth mentioning that this thread is passed to the task scheduler because it needs to run tasks sequentially, in the same thread as the actions performed immediately.

// inside the GameWebsocketHandler class constructor
this.game = GameEntry.init(
                new WebfluxClientsNotificator(sessionManager, new TextSerializer()),
                new WebfluxGameTasksScheduler(GAME_SCHEDULER)
);

@Slf4j
public class WebfluxGameTasksScheduler implements GameTasksScheduler {

    private final Scheduler mainGameScheduler;

    public WebfluxGameTasksScheduler(Scheduler mainGameScheduler) {
        this.mainGameScheduler = mainGameScheduler;
    }

    @Override
    public void schedule(List<Task> tasks) {
        Flux.fromIterable(tasks)
                .subscribeOn(Schedulers.boundedElastic())
                .flatMap(task ->
                        Mono.delay(task.delay())
                                .publishOn(mainGameScheduler)
                                .doOnNext(ignored -> task.action().run())
                )
                .onErrorContinue((throwable, o) -> {
                    log.error("Error in task execution: " + throwable.getMessage());
                })
                .subscribe();
    }
}

The last piece of code I want to present is responsible for sending the dedicated stream to each of the clients:

@Slf4j
public class UserSession {
    private final Sinks.Many<Supplier<ByteBuffer>> messageSink;

    public UserSession(WebSocketSession session) {
        this.messageSink = Sinks.many().unicast().onBackpressureBuffer();
        this.messageSink.asFlux()
                .publishOn(Schedulers.boundedElastic())
                .map(Supplier::get)
                .flatMap(message ->
                        session.send(Mono.just(session.binaryMessage(dataBufferFactory -> dataBufferFactory.wrap(message))))
                                .doOnError(e -> log.error("Error sending message on thread: {}", Thread.currentThread().getName(), e))
                )
                .subscribe();
    }

    public void send(Supplier<ByteBuffer> message) {
        messageSink.tryEmitNext(message);
    }

    public void close() {
        messageSink.tryEmitComplete();
    }
}

It is worth noting that the byte buffer solution, i.e., the serialization, is executed on separate threads.

Map and managers

Until now, we stored the map state in a two-dimensional array of Field objects. At some point, I decided to switch to using java.util.Map, where the key is Position and the value is Field.

    private final Map<Position, Field> map;

Another change is moving the game logic code from the GameEntry class (which implements the Game interface and should serve only as a facade) to separate classes responsible for specific tasks, such as PlayerSpawnManager or PlayerWalkManager.

Storing players

Since we are introducing the display of other players and notifying about their actions, we had to add a new part of the game state, which is storing information about players. Generally speaking, the Field object contains information about the player (if they are on that field), so theoretically, there would be no need to introduce additional state. However, since we always have the player's identifier as input, filtering the entire map to find a player based on their ID could be inefficient. Therefore, I introduced the following entity:

    private final Map<Short, Position> playerIdToPositionMap;

This allows us to quickly obtain the current position of a player based on their identifier, then locate the specific field, and ultimately find the player on it.

Frontend development

Alright, it's worth writing a bit about the frontend, even though most of the interesting things are on the server side. First and foremost, I had to prepare the client-side code to be ready for displaying other players and the events concerning them. Additionally, I separated the code responsible for the scene from the code responsible for the logic of displaying objects. Another important fix was removing the mapSprites object, which was a container for all objects except the player, as it caused the loss of Z-coordinate (depth) information for sprites in the game. This change means that now, when the player moves, I have to shift all elements in the game except the player, instead of just one container.

Let's start by presenting the structure of the game world on the frontend side in its new form. Below is the object holding the entire map:

private matrix: Field[][];

Representation of a single field:

export interface Field {
  tile: Tile;
  otherPlayer: OtherPlayer | null;
}

Class responsible for the surface (the unsightly switch-case was later refactored):

export class Tile {
  private scene: MainScene;
  private sprite: Phaser.GameObjects.Sprite;

  constructor(scene: MainScene, pos: Vector, tileId: number) {
    this.scene = scene;
    let spriteKey: string;
    switch (tileId) {
      case 0x00:
        spriteKey = "grass";
        break;
      case 0x01:
        spriteKey = "water";
        break;
      case 0x02:
        spriteKey = "pavement";
        break;
      case 0x03:
        spriteKey = "black_plate";
        break;
      case 0x04:
        spriteKey = "sand";
        break;
    }

    this.sprite = scene.add
      .sprite(pos.x, pos.y, spriteKey!)
      .setDisplaySize(TILE_SIZE, TILE_SIZE)
      .setDepth(0);
  }

  move(v: Vector) {
    this.sprite.setX(this.sprite.x + v.x);
    this.sprite.setY(this.sprite.y + v.y);
  }

  updatePos(pixelPos: Vector) {
    this.sprite.setX(pixelPos.x);
    this.sprite.setY(pixelPos.y);
  }

  destroy() {
    this.sprite.destroy(true);
  }

  getX() {
    return this.sprite.x;
  }

  getY() {
    return this.sprite.y;
  }

  getPixelPosition(): Vector {
    return {
      x: this.getX(),
      y: this.getY(),
    };
  }

  getSprite() {
    return this.sprite;
  }
}

Class for another player:

export class OtherPlayer {
  private scene: MainScene;

  private characterSprite: Phaser.GameObjects.Sprite;
  private nameSprite: Phaser.GameObjects.Text;
  private healthBarSprite: Phaser.GameObjects.Graphics;

  private id: number;
  private walkTimeInMillis: number;
  private walkStartTime: number;
  private walkDirection: Direction | null;

  constructor(
    scene: MainScene,
    pixelPos: Vector,
    name: string,
    standDirection: Direction,
    localY: number,
    id: number
  ) {
    this.scene = scene;
    this.id = id;
    this.walkTimeInMillis = 0;
    this.walkStartTime = 0;
    this.walkDirection = null;

    const spritePos = this.spritePos(pixelPos);

    this.characterSprite = scene.add
      .sprite(spritePos.x, spritePos.y, "player")
      .setDisplaySize(TILE_SIZE, TILE_SIZE)
      .setFrame(standDirectionMap.get(standDirection)!)
      .setDepth(localY);

    const nickPos = this.nickPos(pixelPos);

    this.nameSprite = scene.add
      .text(nickPos.x, nickPos.y, name, {
        font: "10px Pixelify Sans",
        color: "#008000",
        align: "center",
        resolution: 32,
      })
      .setOrigin(0.5, 0.5)
      .setStroke("#000000", 2)
      .setDepth(localY);

    const healthBarPos = this.healthBarPos(pixelPos);

    this.healthBarSprite = scene.add.graphics();
    this.healthBarSprite.fillStyle(0x000000, 1);
    this.healthBarSprite.fillRect(
      -OUTLINE_THICKNESS,
      -OUTLINE_THICKNESS,
      BAR_WIDTH + OUTLINE_THICKNESS * 2,
      BAR_HEIGHT + OUTLINE_THICKNESS * 2
    );

    this.healthBarSprite.fillStyle(0x008000, 1);
    this.healthBarSprite.fillRect(0, 0, BAR_WIDTH, BAR_HEIGHT);
    this.healthBarSprite.setX(healthBarPos.x);
    this.healthBarSprite.setY(healthBarPos.y);
    this.healthBarSprite.setDepth(localY);
  }

  move(ds: Vector) {
    this.characterSprite.setX(this.characterSprite.x + ds.x);
    this.characterSprite.setY(this.characterSprite.y + ds.y);

    this.nameSprite.setX(this.nameSprite.x + ds.x);
    this.nameSprite.setY(this.nameSprite.y + ds.y);

    this.healthBarSprite.setX(this.healthBarSprite.x + ds.x);
    this.healthBarSprite.setY(this.healthBarSprite.y + ds.y);
  }

  updatePos(pixelPos: Vector, localY: number) {
    const spritePos = this.spritePos(pixelPos);
    this.characterSprite.setX(spritePos.x);
    this.characterSprite.setY(spritePos.y);
    this.characterSprite.setDepth(localY);

    const nickPos = this.nickPos(pixelPos);
    this.nameSprite.setX(nickPos.x);
    this.nameSprite.setY(nickPos.y);
    this.nameSprite.setDepth(localY);

    const healthBarPos = this.healthBarPos(pixelPos);
    this.healthBarSprite.setX(healthBarPos.x);
    this.healthBarSprite.setY(healthBarPos.y);
    this.healthBarSprite.setDepth(localY);
  }

  startWalk(
    walkTimeInMillis: number,
    walkDirection: Direction,
    anim: string,
    pixelPos: Vector,
    localY: number
  ) {
    this.updatePos(pixelPos, localY);

    this.walkTimeInMillis = walkTimeInMillis;
    this.walkStartTime = this.scene.time.now;
    this.walkDirection = walkDirection;
    this.characterSprite.anims.play(anim, true);
  }

  updateWalk(time: number, delta: number) {
    if (time - this.walkStartTime <= this.walkTimeInMillis) {
      const dx =
        walks.get(this.walkDirection!)!.xFactor *
        -TILE_SIZE *
        (delta / this.walkTimeInMillis);
      const dy =
        walks.get(this.walkDirection!)!.yFactor *
        -TILE_SIZE *
        (delta / this.walkTimeInMillis);

      const ds = { x: dx, y: dy };

      this.move(ds);
    }
  }

  finishWalk(standDirection: Direction, pixelPos: Vector, localY: number) {
    this.characterSprite.anims.stop();
    this.characterSprite.setFrame(standDirectionMap.get(standDirection)!);
    this.walkDirection = null;

    this.updatePos(pixelPos, localY);
  }

  isWalking() {
    return this.walkDirection !== null;
  }

  rotate(newStandDirection: Direction) {
    this.characterSprite.setFrame(standDirectionMap.get(newStandDirection)!);
  }

  destroy() {
    this.characterSprite.destroy(true);
    this.nameSprite.destroy(true);
    this.healthBarSprite.destroy(true);
  }

  getId() {
    return this.id;
  }

  private spritePos(pos: Vector) {
    return {
      x: pos.x + SPRITE_OFFSET.x,
      y: pos.y + SPRITE_OFFSET.y,
    };
  }

  private nickPos(pos: Vector) {
    return {
      x: pos.x + NICK_OFFSET.x,
      y: pos.y + NICK_OFFSET.y,
    };
  }

  private healthBarPos(pos: Vector) {
    return {
      x: pos.x + HEALTH_BAR_OFFSET.x,
      y: pos.y + HEALTH_BAR_OFFSET.y,
    };
  }
}

The class representing the current player looks practically identical, with minor differences. Therefore, I later extracted a common abstract class with shared code. It is also worth adding that the current player object does not belong to the world structure, as it is always displayed in the center of the screen, so there is no point in storing it in the map.

Player rotation and smooth movement

Rotation

Let's start with the player rotation functionality. This is one of the simpler topics. The code looks like this:

    public void rotate(short executorId, Direction newStandDirection) {
        final var currentPlayerPos = playerIdToPositionMap.get(executorId);
        final var currentPlayerField = map.get(currentPlayerPos);
        final var player = currentPlayerField.getPlayer();

        if (player.rotateAndCheckSuccess(newStandDirection)) {
            clientsNotificator.showRotate(spectatorsCalculator.calculate(executorId, currentPlayerPos), executorId, player.toVisibleObject());
        }
    }

If the rotation is successful, we notify all observers. Rotation is considered successful when we want to turn the player in a different direction than they are currently facing and if they are not in the middle of a walk.

Smooth walking

To ensure continuous movement, I introduced a mechanism for storing information about the next move and the next rotation. In short, it works like this: if a player sends a walk or rotation command while they are currently moving, we save this command as the next step. This way, when the player finishes walking to a given tile, they only stop if there are no more planned moves.

Player information during map loading

Until now, when loading the map at the beginning of the game, we sent tiles with information about the surface and optionally the player's nickname, although the latter was not yet utilized. Now, to correctly display the current state of each character, we need to add complete information. Besides the nickname, it is necessary to transmit the standing direction, as well as the direction and duration of the current walk (if the player is moving).

public record FieldView(byte tileId, CharacterView characterView) {

    public Optional<CharacterView> getCharacterView() {
        return Optional.ofNullable(characterView);
    }
}
public record CharacterView(short id, String name, Direction standDirection, Direction walkDirection, short walkTimeInMillis) {
}

Notifying observers of visible actions

Until now, only the player performing an action could see what was happening to their character in the game. For example, if they occupied a specific tile, another player would not see this, even though they could not occupy that tile (the server did not allow it). We will change this. Below is the code responsible for notifying about player rotation:

    @Override
    public void showRotate(Set<Short> spectators, short receiverId, CharacterView characterView) {
        sessionManager.sendMessageToUser(receiverId, () -> new CommandSerializer(ROTATE_CLIENT_COMMANDS_MAP.get(characterView.standDirection()))
                .serialize());

        sessionManager.broadcastMessage(spectators, () -> new CommandSerializer(ROTATE_TO_SPECTATORS_CLIENT_COMMANDS_MAP.get(characterView.standDirection()))
                .writeShort(characterView.id())
                .serialize()
        );
    }

receiverId is the player performing the action, and spectators are the observers calculated based on the current position on the map. In the next steps, I simplified this by adding the current player to the observers and sending a single command (the frontend, based on the executor's ID, knows whether to update the current or another player).

Notice that the methods send and broadcastMessage do not receive serialized messages, but rather a function that supplies these messages. This improves performance since the serialization process occurs on separate threads, not blocking the main game thread, which handles calculations.

Map loading and clearing

The last point of today's post, dynamic map loading and clearing, probably took me the most time. Previously, we displayed a static area expanded by 3 tiles in each direction relative to what the player sees on the screen. Initially, the client received a map fragment measuring 19 rows and 23 columns. As the player moved in a direction, they eventually reached the edge of the visible map.

Now we will introduce a few changes. First, the client will initially receive a much larger map area measuring 39 rows and 51 columns. This is effectively an area consisting of 9 smaller fragments, where the dimension of each corresponds to the area visible to the player. The illustration below shows everything. The green color represents the area visible on the screen, and the orange is the central tile where the player is located.

Game map structure – 39x51 tile grid visualizing the visible 13x17 area centered on the player and the 8 surrounding map chunks for dynamic loading or camera logic

When the player moves a distance of 7 rows or 9 columns (half of the visible area) from the point of the last map loading, we load a new area and remove the unnecessary one in such a way that the player is once again in the center of the updated map fragment. It will be easier to illustrate this with diagrams. Let's assume the player moved 7 tiles up and 2 tiles to the left. Their new position is the darker orange color:

Game map visualization – full 39x51 tile grid with 9 highlighted 13x17 chunks and updated player position indicating dynamic camera or chunk transition logic

Let's mark the fragment we are loading in yellow, and the fragment we are removing in red (unfortunately, I had to use a different graphic tool):

Dynamic map loading illustration – image showing active tile segments with one area being loaded and another cleaned up as part of a chunk-based system in a 2D game

Talk to you soon! :)