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
- DATABASE (players_state table):
- room_id: 41, x: 1520, y: 1424, dir: 'down'
- NO scene property.
- LOAD - UsersManager.loadUserByUsername():
- loadUserByProperty() loads the related_users_login and related_players.related_players_state relations.
- related_players[].related_players_state = database snapshot.
- MAP - LoginManager.mapPlayerStateRelation():
- player.state = player.related_players_state (the assignment creates the runtime state).
- ENHANCE - LoginManager.setSceneOnPlayers():
- player.state.scene = PlayerRoomState.getRoomNameById(player.state.room_id) (adds the scene property to the runtime state).
- SELECT - RoomLogin.onAuth():
- userModel.player = sc.fetchByProperty(related_players, 'id', selectedPlayer) (assigns the selected player to userModel.player).
- 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.
- GAMEPLAY - the player moves and changes scenes:
- Updates: playerSchema.state (runtime).
- Unchanged: player.related_players_state (stale).
- 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
- The related_ prefix is the current database relation naming (not legacy).
- related_players_state = database snapshot loaded on login (the players_state table has no scene column).
- player.state = login state (has the scene property, used to build the room player schema).
- The scene property is only added at runtime, NOT in the database model.
- Validation must use player.state.scene, NOT player.related_players_state.scene.
- Database updates read from playerSchema.state and write to the players_state table.
- 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
- Rooms Entity - the scene property maps to the room names.
- Guest System - guest login and the guest rooms join gate.
- Storage Architecture - related_ keys and entity relations.
- Class Path Entity - player progression on top of the player state.
- Feature Modules - lib/users/ and lib/rooms/ internals.
reldens