Player State Flow

Complete technical guide to how the player state moves from the database to runtime memory during login and gameplay, and where the related_ relations naming convention fits in.

Architecture Layers

1. Database layer (persistent storage)

All database relations use the related_ prefix (this is the current convention, NOT legacy):

UsersModel {
  id: number,
  email: string,
  username: string,
  password: string,
  role_id: number,

  // Database relations with the "related_" prefix
  related_users_login: UsersLoginModel[],
  // Array of all players for this user
  related_players: PlayersModel[]
}

PlayersModel {
  id: number,
  user_id: number,
  name: string,
  created_at: Date,
  updated_at: Date,

  // Player state from database (persistent)
  related_players_state: PlayersStateModel {
    id: number,
    player_id: number,
    // Last SAVED room
    room_id: number,
    // Last SAVED position
    x: number,
    y: number,
    dir: string
    // NOTE: NO scene property in database model!
  }
}

Key points:

  • related_players is an array (users can have multiple characters).
  • related_players_state is the database snapshot of the player position.
  • The database model does NOT include a scene property (only room_id).

2. Runtime layer (in-memory during gameplay)

During login and gameplay, additional properties are added for the runtime state management:

// After login processing:
userModel {
  ...database fields,
  // From database
  related_players: PlayersModel[],

  // ADDED AT RUNTIME: Selected player reference
  // Selected from related_players[]
  player: PlayersModel {
    ...database fields,
    // Database snapshot
    related_players_state: { ... },

    // ADDED AT RUNTIME: login state used to build the room player schema
    state: {
      // Room loaded from the database (or from the scene selected on login)
      room_id: number,
      // Position loaded from the database
      x: number,
      y: number,
      dir: string,
      // ADDED: Room name (not in database!)
      scene: string
    }
  }
}

Key points:

  • userModel.player is assigned at runtime from related_players[].
  • player.state is created during login and is not updated during gameplay (the room updates playerSchema.state instead).
  • player.state.scene is added by the server, not loaded from the database.
  • player.state is the same object as player.related_players_state unless applySelectedLocation() replaces it.

Complete Login Flow

Step 1: User authentication

RoomLogin.onAuth (lib/rooms/server/login.js). After validating the options, the request origin and the joins limit, onAuth loads and validates the user through the login manager:

let loginResult = await this.loginManager.processUserRequest(options, requestAddress);
if(sc.hasOwn(loginResult, 'error')){
    ErrorManager.error(loginResult.error);
}

Then it selects the player (Step 5), emits reldens.roomLoginOnAuth, and returns the user, which becomes the userModel received by onJoin:

return await this.disconnectFromOtherServers(loginResult.user);

Step 2: Load the user from the database

UsersManager.loadUserByUsername, which calls UsersManager.loadUserByProperty (lib/users/server/manager.js). LoginManager.processReservedUserRequest calls this.usersManager.loadUserByUsername(userData.username), and loadUserByProperty loads the players WITH their state from the database:

let loadedUser = await this.usersRepository.loadOneByWithRelations(
    propertyKey,
    propertyValue,
    ['related_users_login', 'related_players.related_players_state']
);

Result: the user is loaded with the related_players[] array, and each player has its related_players_state from the database.

Step 3: Map the player state relation

LoginManager.mapPlayerStateRelation (lib/game/server/login-manager.js). LoginManager.login runs steps 3 and 4 after the password validation, when the user has players:

if(sc.isArray(user.related_players) && 0 < user.related_players.length){
    this.mapPlayerStateRelation(user);
    // set the scene on the user players:
    this.events.emitSync('reldens.setSceneOnPlayers', this, user, userData);
    await this.setSceneOnPlayers(user, userData);
}
mapPlayerStateRelation(user)
{
    if(!sc.isArray(user.related_players)){
        return;
    }
    for(let player of user.related_players){
        if(player.related_players_state && !player.state){
            player.state = player.related_players_state;
        }
    }
}

Critical: this creates player.state by assigning player.related_players_state.

Is this assignment by reference or a copy?

  • In JavaScript, object assignment is by reference.
  • The Knex driver returns plain row objects, so player.state and player.related_players_state are the same mutable object.
  • Result: the scene added in Step 4 is visible on both; they only become different objects when applySelectedLocation() replaces player.state.

Step 4: Set the scene on the players

LoginManager.setSceneOnPlayers (lib/game/server/login-manager.js). When the scene selection on login is allowed and a valid scene was selected, applySelectedLocation replaces player.state; then the scene property is ADDED to the state (it is not in the database):

for(let player of user.related_players){
    if(!player.state){
        continue;
    }
    let config = this.config.get('client/rooms/selection');
    if(
        config.allowOnLogin
        && userData['selectedScene']
        && userData['selectedScene'] !== RoomsConst.ROOM_LAST_LOCATION_KEY
        && this.roomsManager.loginAvailableRooms.some(room => room.name === userData['selectedScene'])
    ){
        await this.applySelectedLocation(player, userData['selectedScene']);
    }
    player.state.scene = await this.playerRoomState.getRoomNameById(player.state.room_id);
}

Result: each player now has player.state.scene with the room name string.

Step 5: Select the player (runtime assignment)

In RoomLogin.onAuth (lib/rooms/server/login.js), an id that is not in the related_players array rejects the login:

if(sc.hasOwn(options, 'selectedPlayer')){
    let playerModel = sc.fetchByProperty(loginResult.user.related_players, 'id', options.selectedPlayer);
    if(!playerModel){
        Logger.warning('Auth invalid selected player.', {username: loginResult.user.username});
        ErrorManager.error(GameConst.INVALID_LOGIN_MESSAGE);
    }
    loginResult.selectedPlayer = options.selectedPlayer;
    loginResult.user.player = playerModel;
}

Result: userModel.player now references ONE player from the array with both:

  • player.related_players_state (database snapshot).
  • player.state (runtime state with the scene).

Gameplay Flow

Joining a scene room

RoomScene.onJoin (lib/rooms/server/scene.js) selects the player again from options.selectedPlayer and validates the RUNTIME state (not the database state): a missing state, an invalid room or a scene that is not this room rejects the join with GameConst.JOIN_GAME_ERROR_MESSAGE:

if(sc.hasOwn(options, 'selectedPlayer')){
    userModel.selectedPlayer = options.selectedPlayer;
    userModel.player = sc.fetchByProperty(userModel.related_players, 'id', options.selectedPlayer);
}
let isGuest = this.loginManager.isGuestUser(userModel);
if(this.validateRoomData){
    // @NOTE: here we use userModel.player.state since it is the runtime dynamic data applied on the userModel.
    if(!userModel.player?.state){
        Logger.warning('Missing user player state.', {userId: userModel.id, username: userModel.username});
        ErrorManager.error(GameConst.JOIN_GAME_ERROR_MESSAGE);
    }
    if(!this.validateRoom(userModel.player.state.scene, isGuest)){
        await this.events.emit('reldens.joinRoomInvalid', this, client, options, userModel, isGuest);
        ErrorManager.error(GameConst.JOIN_GAME_ERROR_MESSAGE);
    }
    if(userModel.player.state.scene !== this.roomName){
        Logger.warning(
            'Player scene "'+userModel.player.state.scene+'" does not match room "'+this.roomName+'".'
        );
        await this.events.emit('reldens.joinRoomInvalid', this, client, options, userModel, isGuest);
        ErrorManager.error(GameConst.JOIN_GAME_ERROR_MESSAGE);
    }
}

After the validation createPlayerOnScene creates the player schema in the room.

Note: the validation must read state.scene; related_players_state.scene does not exist in the database model.

Saving the player state during gameplay

RoomScene.savePlayerState (lib/rooms/server/scene.js). The CURRENT position is read from the runtime playerSchema.state, NOT from related_players_state:

let playerSchema = this.playerBySessionIdFromState(sessionId);
let {room_id, x, y, dir} = playerSchema.state;
let playerId = playerSchema.player_id;
let updatePatch = {room_id, x: parseInt(x), y: parseInt(y), dir};

After the reldens.onSavePlayerStateBefore event (a listener can stop the update), the database is updated with the CURRENT position:

updateResult = await this.loginManager.usersManager.updateUserStateByPlayerId(playerId, updatePatch);

Key points:

  • The database is updated FROM playerSchema.state (runtime).
  • The database is updated TO the players_state table (which becomes related_players_state on the next login).
  • related_players_state in the current session is NEVER updated after login (it remains stale).

Data Flow

  1. DATABASE (players_state table):
    • room_id: 41, x: 1520, y: 1424, dir: 'down'
    • NO scene property.
  2. LOAD - UsersManager.loadUserByUsername():
    • loadUserByProperty() loads the related_users_login and related_players.related_players_state relations.
    • related_players[].related_players_state = database snapshot.
  3. MAP - LoginManager.mapPlayerStateRelation():
    • player.state = player.related_players_state (the assignment creates the runtime state).
  4. ENHANCE - LoginManager.setSceneOnPlayers():
    • player.state.scene = PlayerRoomState.getRoomNameById(player.state.room_id) (adds the scene property to the runtime state).
  5. SELECT - RoomLogin.onAuth():
    • userModel.player = sc.fetchByProperty(related_players, 'id', selectedPlayer) (assigns the selected player to userModel.player).
  6. VALIDATE - RoomScene.onJoin():
    • Re-selects userModel.player from options.selectedPlayer.
    • Checks that userModel.player.state exists.
    • Validates that userModel.player.state.scene matches the room.
  7. GAMEPLAY - the player moves and changes scenes:
    • Updates: playerSchema.state (runtime).
    • Unchanged: player.related_players_state (stale).
  8. SAVE - RoomScene.savePlayerState():
    • Reads FROM playerSchema.state (current position).
    • Writes TO the database players_state table (it becomes related_players_state on the next login).

State Divergence

After login, there are TWO sources of state that diverge.

Initial login: userModel.player.state is the same object as userModel.player.related_players_state, with scene added on top of it:

userModel.player.state = {
  // Town (from database)
  room_id: 41,
  x: 1520,
  y: 1424,
  dir: 'down',
  // Added by server
  scene: 'reldens-new-age-town'
}

After a scene change (the player moves to a house): the room updates the Colyseus schema built from that state in the Player constructor (lib/users/server/player.js, this.state = new BodyState(player.state)), not player.state itself:

// UNCHANGED for the whole session:
userModel.player.state = {
  room_id: 41,
  x: 1520,
  y: 1424,
  dir: 'down',
  scene: 'reldens-new-age-town'
}

// UPDATED during gameplay:
playerSchema.state = {
  room_id: 2,
  x: 528,
  y: 624,
  dir: 'down',
  scene: 'reldens-house-1'
}

On logout, playerSchema.state is saved to the database and becomes related_players_state on the next login.

Key Takeaways

  1. The related_ prefix is the current database relation naming (not legacy).
  2. related_players_state = database snapshot loaded on login (the players_state table has no scene column).
  3. player.state = login state (has the scene property, used to build the room player schema).
  4. The scene property is only added at runtime, NOT in the database model.
  5. Validation must use player.state.scene, NOT player.related_players_state.scene.
  6. Database updates read from playerSchema.state and write to the players_state table.
  7. player.state is never updated during gameplay (the room updates playerSchema.state).

Code References

Key files:

  • lib/users/server/manager.js (UsersManager.loadUserByUsername, UsersManager.loadUserByProperty) - load the user with relations.
  • lib/game/server/login-manager.js (LoginManager.mapPlayerStateRelation) - map the player state relation.
  • lib/game/server/login-manager.js (LoginManager.setSceneOnPlayers) - set the scene on the players.
  • lib/rooms/server/login.js (RoomLogin.onAuth) - authentication and player selection.
  • lib/rooms/server/scene.js (RoomScene.onJoin) - scene validation.
  • lib/rooms/server/scene.js (RoomScene.savePlayerState) - save the player state.

Database tables:

  • users - user accounts.
  • players - player characters.
  • players_state - player positions (becomes related_players_state when loaded).

Entity relations:

  • UsersModel.related_players relates to PlayersModel[].
  • PlayersModel.related_players_state relates to PlayersStateModel.

Related Documentation

Go Up