diff --git a/client/index.html b/client/index.html
index d20c926..821d31a 100644
--- a/client/index.html
+++ b/client/index.html
@@ -19,7 +19,6 @@
-
Register
@@ -43,11 +42,12 @@
+
diff --git a/client/public/version.js b/client/public/version.js
index 548862f..2b1b283 100644
--- a/client/public/version.js
+++ b/client/public/version.js
@@ -1,5 +1,5 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
-window.CHGRID_WEB_VERSION = "2026.02.25 R250";
+window.CHGRID_WEB_VERSION = "2026.02.25 R251";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";
diff --git a/client/src/main.ts b/client/src/main.ts
index a108cb2..327e88c 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -1130,39 +1130,6 @@ function formatCoordinate(value: number): string {
return value.toFixed(2).replace(/\.?0+$/, '');
}
-/** Persists current local player coordinates for reconnect/refresh restore. */
-function persistPlayerPosition(): void {
- try {
- const positionKey = getPlayerPositionStorageKey();
- localStorage.setItem(
- positionKey,
- JSON.stringify({ x: state.player.x, y: state.player.y }),
- );
- } catch {
- // Ignore storage failures (private mode/quota/blocked storage).
- }
-}
-
-/** Loads previously persisted local player coordinates, when available and valid. */
-function getPersistedPlayerPosition(): { x: number; y: number } | null {
- const raw = localStorage.getItem(getPlayerPositionStorageKey());
- if (!raw) return null;
- try {
- const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown };
- if (typeof parsed.x !== 'number' || typeof parsed.y !== 'number') return null;
- if (!Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) return null;
- return { x: Math.round(parsed.x), y: Math.round(parsed.y) };
- } catch {
- return null;
- }
-}
-
-/** Resolves local storage key for per-account saved player position. */
-function getPlayerPositionStorageKey(): string {
- const usernameKey = sanitizeAuthUsername(authUsername);
- return usernameKey ? `spatialChatPosition:${usernameKey}` : 'spatialChatPosition';
-}
-
/** Picks one random footstep sample URL. */
function randomFootstepUrl(): string {
return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)];
@@ -1248,7 +1215,6 @@ function updateTeleport(): void {
signaling.send({ type: 'teleport_complete', x: activeTeleport.targetX, y: activeTeleport.targetY });
activeTeleport = null;
stopTeleportLoopAudio();
- persistPlayerPosition();
void refreshAudioSubscriptions(true);
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
updateStatus(completionStatus);
@@ -1305,7 +1271,6 @@ function handleMovement(): void {
state.player.x = nextX;
state.player.y = nextY;
lastWallCollisionDirection = null;
- persistPlayerPosition();
state.player.lastMoveTime = now;
void refreshAudioSubscriptions(true);
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs);
@@ -1557,7 +1522,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
pushChatMessage(message);
},
updateConnectAvailability,
- settingsSaveNickname: (value) => settings.saveNickname(value),
mediaIsConnecting: () => mediaSession.isConnecting(),
mediaSetConnecting: (value) => mediaSession.setConnecting(value),
mediaCheckMicPermission: () => checkMicPermission(),
@@ -1570,7 +1534,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
signalingSendAuth: () => sendAuthRequest(),
signalingDisconnect: () => signaling.disconnect(),
onMessage: (message) => onSignalingMessage(message as IncomingMessage),
- persistPlayerPosition,
peerManagerCleanupAll: () => peerManager.cleanupAll(),
radioCleanupAll: () => radioRuntime.cleanupAll(),
emitCleanupAll: () => itemEmitRuntime.cleanupAll(),
@@ -1615,7 +1578,6 @@ const onAppMessage = createOnMessageHandler({
mediaSession.setConnecting(value);
updateConnectAvailability();
},
- getPersistedPlayerPosition,
rendererSetGridSize: (size) => renderer.setGridSize(size),
applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters[0]),
state,
@@ -2670,10 +2632,6 @@ function setupUiHandlers(): void {
},
updateDeviceSummary,
setOutputDevice: (id) => peerManager.setOutputDevice(id),
- persistOnUnload: () => {
- if (!state.running) return;
- persistPlayerPosition();
- },
});
dom.showRegisterButton.addEventListener('click', () => {
setAuthMode('register');
diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts
index d315cad..efa7cb6 100644
--- a/client/src/network/messageHandlers.ts
+++ b/client/src/network/messageHandlers.ts
@@ -9,7 +9,6 @@ type MessageHandlerDeps = {
setWorldGridSize: (size: number) => void;
setMovementTickMs: (value: number) => void;
setConnecting: (value: boolean) => void;
- getPersistedPlayerPosition: () => { x: number; y: number } | null;
rendererSetGridSize: (size: number) => void;
applyServerItemUiDefinitions: (defs: unknown) => boolean;
state: {
@@ -105,11 +104,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.state.player.id = message.id;
deps.state.running = true;
deps.setConnecting(false);
- const persistedPosition = deps.getPersistedPlayerPosition();
- const targetX = persistedPosition?.x ?? message.player.x;
- const targetY = persistedPosition?.y ?? message.player.y;
- deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, targetX));
- deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, targetY));
+ deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.x));
+ deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.y));
deps.dom.connectButton.classList.add('hidden');
deps.dom.disconnectButton.classList.remove('hidden');
deps.dom.focusGridButton.classList.remove('hidden');
diff --git a/client/src/session/connectionFlow.ts b/client/src/session/connectionFlow.ts
index cf6f49e..adf6964 100644
--- a/client/src/session/connectionFlow.ts
+++ b/client/src/session/connectionFlow.ts
@@ -17,7 +17,6 @@ export type ConnectFlowDeps = {
sanitizeName: (value: string) => string;
updateStatus: (message: string) => void;
updateConnectAvailability: () => void;
- settingsSaveNickname: (value: string) => void;
mediaIsConnecting: () => boolean;
mediaSetConnecting: (value: boolean) => void;
mediaCheckMicPermission: () => Promise;
@@ -30,7 +29,6 @@ export type ConnectFlowDeps = {
signalingSendAuth: () => void;
signalingDisconnect: () => void;
onMessage: (message: unknown) => Promise;
- persistPlayerPosition: () => void;
peerManagerCleanupAll: () => void;
radioCleanupAll: () => void;
emitCleanupAll: () => void;
@@ -46,9 +44,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise {
}
const nickname = deps.sanitizeName(deps.state.player.nickname);
deps.state.player.nickname = nickname || deps.state.player.nickname;
- if (nickname) {
- deps.settingsSaveNickname(nickname);
- }
deps.mediaSetConnecting(true);
deps.updateConnectAvailability();
@@ -105,9 +100,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise {
*/
export function runDisconnectFlow(deps: ConnectFlowDeps): void {
const wasRunning = deps.state.running;
- if (deps.state.running) {
- deps.persistPlayerPosition();
- }
deps.signalingDisconnect();
deps.mediaStopLocalMedia();
diff --git a/client/src/ui/domBindings.ts b/client/src/ui/domBindings.ts
index 0c88f3b..fbee96d 100644
--- a/client/src/ui/domBindings.ts
+++ b/client/src/ui/domBindings.ts
@@ -30,16 +30,12 @@ type UiBindingsDeps = {
setPreferredOutput: (id: string, name: string) => void;
updateDeviceSummary: () => void;
setOutputDevice: (id: string) => Promise;
- persistOnUnload: () => void;
};
/**
* Attaches UI listeners (connect/settings/device changes) and focus traps.
*/
export function setupUiHandlers(deps: UiBindingsDeps): void {
- window.addEventListener('pagehide', deps.persistOnUnload);
- window.addEventListener('beforeunload', deps.persistOnUnload);
-
deps.dom.connectButton.addEventListener('click', () => {
void deps.connect();
});
diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md
index d9e63fd..1b8f018 100644
--- a/docs/protocol-notes.md
+++ b/docs/protocol-notes.md
@@ -86,6 +86,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- Server is authoritative for all action validation and normalization.
- Server is authoritative for movement acceptance (bounds + rate/delta checks).
+- Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume.
- Client validates incoming packet shapes and applies runtime behavior.
- Sound/media field normalization uses shared server policy helpers:
- `none/off` normalize to empty values
diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md
index 88c34f7..2ed2c12 100644
--- a/docs/runtime-flow.md
+++ b/docs/runtime-flow.md
@@ -14,7 +14,7 @@
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
- - uses `welcome.player` as authoritative starting position
+ - uses `welcome.player` as authoritative starting position (restored from server-side account state when available)
- records `welcome.serverInfo` (`instanceId`, `version`) for restart detection
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page
- applies `welcome.uiDefinitions` for item menus/properties/options
diff --git a/server/app/auth_service.py b/server/app/auth_service.py
index 82fec3b..7eb2be4 100644
--- a/server/app/auth_service.py
+++ b/server/app/auth_service.py
@@ -31,6 +31,8 @@ class AuthUser:
status: str
email: str | None
last_nickname: str | None
+ last_x: int | None
+ last_y: int | None
@dataclass(frozen=True)
@@ -115,8 +117,8 @@ class AuthService:
self._conn.execute(
"""
INSERT INTO users (
- username, password_hash, email, role, status, last_nickname, created_at_ms, updated_at_ms, last_login_at_ms
- ) VALUES (?, ?, ?, ?, 'active', NULL, ?, ?, ?)
+ username, password_hash, email, role, status, created_at_ms, updated_at_ms, last_login_at_ms
+ ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
""",
(normalized_username, password_hash, normalized_email, role, now_ms, now_ms, now_ms),
)
@@ -131,6 +133,24 @@ class AuthService:
user = self._get_user_by_username(normalized_username)
if user is None:
raise AuthError("Failed to load newly created user.")
+ self._conn.execute(
+ """
+ INSERT OR IGNORE INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms)
+ VALUES (?, ?, NULL, NULL, ?)
+ """,
+ (int(user.id), user.username, now_ms),
+ )
+ self._conn.commit()
+ user = AuthUser(
+ id=user.id,
+ username=user.username,
+ role=user.role,
+ status=user.status,
+ email=user.email,
+ last_nickname=user.username,
+ last_x=user.last_x,
+ last_y=user.last_y,
+ )
return self._create_session(user)
def login(self, username: str, password: str) -> AuthSession:
@@ -139,9 +159,19 @@ class AuthService:
normalized_username = self._normalize_username(username)
user_row = self._conn.execute(
"""
- SELECT id, username, password_hash, email, role, status, last_nickname
- FROM users
- WHERE username = ?
+ SELECT
+ u.id,
+ u.username,
+ u.password_hash,
+ u.email,
+ u.role,
+ u.status,
+ us.last_nickname,
+ us.last_x,
+ us.last_y
+ FROM users u
+ LEFT JOIN user_state us ON us.user_id = u.id
+ WHERE u.username = ?
""",
(normalized_username,),
).fetchone()
@@ -161,6 +191,8 @@ class AuthService:
status=user.status,
email=user.email,
last_nickname=user.username,
+ last_x=user.last_x,
+ last_y=user.last_y,
)
self._conn.execute(
"UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?",
@@ -179,9 +211,10 @@ class AuthService:
row = self._conn.execute(
"""
SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms,
- u.username, u.role, u.status, u.email, u.last_nickname
+ u.username, u.role, u.status, u.email, us.last_nickname, us.last_x, us.last_y
FROM sessions s
JOIN users u ON u.id = s.user_id
+ LEFT JOIN user_state us ON us.user_id = u.id
WHERE s.token_hash = ?
""",
(token_hash,),
@@ -210,6 +243,8 @@ class AuthService:
status=row["status"],
email=row["email"],
last_nickname=row["last_nickname"],
+ last_x=row["last_x"] if "last_x" in row.keys() else None,
+ last_y=row["last_y"] if "last_y" in row.keys() else None,
)
if not user.last_nickname:
self.set_last_nickname(user.id, user.username)
@@ -220,6 +255,8 @@ class AuthService:
status=user.status,
email=user.email,
last_nickname=user.username,
+ last_x=user.last_x,
+ last_y=user.last_y,
)
return AuthSession(session_id=row["session_id"], token=cleaned, user=user)
@@ -242,11 +279,47 @@ class AuthService:
cleaned = nickname.strip()
if not cleaned:
return
- self._conn.execute(
- "UPDATE users SET last_nickname = ?, updated_at_ms = ? WHERE id = ?",
- (cleaned, self.now_ms(), user_id),
- )
- self._conn.commit()
+ try:
+ user_id_value = int(user_id)
+ except (TypeError, ValueError):
+ return
+ try:
+ self._conn.execute(
+ """
+ INSERT INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms)
+ VALUES (?, ?, NULL, NULL, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ last_nickname = excluded.last_nickname,
+ updated_at_ms = excluded.updated_at_ms
+ """,
+ (user_id_value, cleaned, self.now_ms()),
+ )
+ self._conn.commit()
+ except sqlite3.IntegrityError:
+ self._conn.rollback()
+
+ def set_last_position(self, user_id: str, x: int, y: int) -> None:
+ """Persist last known world position for one user."""
+
+ try:
+ user_id_value = int(user_id)
+ except (TypeError, ValueError):
+ return
+ try:
+ self._conn.execute(
+ """
+ INSERT INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms)
+ VALUES (?, NULL, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ last_x = excluded.last_x,
+ last_y = excluded.last_y,
+ updated_at_ms = excluded.updated_at_ms
+ """,
+ (user_id_value, int(x), int(y), self.now_ms()),
+ )
+ self._conn.commit()
+ except sqlite3.IntegrityError:
+ self._conn.rollback()
@staticmethod
def now_ms() -> int:
@@ -267,7 +340,6 @@ class AuthService:
email TEXT UNIQUE,
role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user',
status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active',
- last_nickname TEXT,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
last_login_at_ms INTEGER
@@ -290,6 +362,18 @@ class AuthService:
)
"""
)
+ self._conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_state (
+ user_id INTEGER PRIMARY KEY,
+ last_nickname TEXT,
+ last_x INTEGER,
+ last_y INTEGER,
+ updated_at_ms INTEGER NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ """
+ )
self._conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)")
self._conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL"
@@ -297,6 +381,7 @@ class AuthService:
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)")
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at_ms)")
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)")
+ self._conn.execute("CREATE INDEX IF NOT EXISTS idx_user_state_updated ON user_state(updated_at_ms)")
self._conn.commit()
def _create_session(self, user: AuthUser) -> AuthSession:
@@ -321,7 +406,20 @@ class AuthService:
"""Fetch one user by normalized username."""
row = self._conn.execute(
- "SELECT id, username, role, status, email, last_nickname FROM users WHERE username = ?",
+ """
+ SELECT
+ u.id,
+ u.username,
+ u.role,
+ u.status,
+ u.email,
+ us.last_nickname,
+ us.last_x,
+ us.last_y
+ FROM users u
+ LEFT JOIN user_state us ON us.user_id = u.id
+ WHERE u.username = ?
+ """,
(username,),
).fetchone()
if row is None:
@@ -338,7 +436,9 @@ class AuthService:
role=row["role"],
status=row["status"],
email=row["email"],
- last_nickname=row["last_nickname"],
+ last_nickname=row["last_nickname"] if "last_nickname" in row.keys() else None,
+ last_x=row["last_x"] if "last_x" in row.keys() else None,
+ last_y=row["last_y"] if "last_y" in row.keys() else None,
)
@staticmethod
diff --git a/server/app/client.py b/server/app/client.py
index 8f3f897..c522f42 100644
--- a/server/app/client.py
+++ b/server/app/client.py
@@ -19,6 +19,8 @@ class ClientConnection:
role: str = "user"
session_token: str | None = None
nickname: str = "user..."
+ saved_x: int | None = None
+ saved_y: int | None = None
x: int = 20
y: int = 20
last_position_update_ms: int = 0
diff --git a/server/app/server.py b/server/app/server.py
index 9273873..1f6c9e4 100644
--- a/server/app/server.py
+++ b/server/app/server.py
@@ -88,6 +88,7 @@ PIANO_RECORDING_MAX_MS = 30_000
PIANO_RECORDING_MAX_EVENTS = 4096
MOVEMENT_TICK_MS = 200
MOVEMENT_MAX_STEPS_PER_TICK = 1
+POSITION_PERSIST_DEBOUNCE_MS = 5_000
class SignalingServer:
@@ -141,6 +142,7 @@ class SignalingServer:
self.state_save_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms))
self._pending_state_save_handle: asyncio.TimerHandle | None = None
self._pending_state_save_started_at: float | None = None
+ self._last_position_persist_ms_by_user: dict[str, int] = {}
@staticmethod
def _resolve_server_version() -> str:
@@ -177,6 +179,19 @@ class SignalingServer:
return nickname.casefold()
+ def _persist_client_position(self, client: ClientConnection, *, force: bool = False) -> None:
+ """Persist one authenticated client's last known position with debounce."""
+
+ if not client.user_id:
+ return
+ now_ms = self.item_service.now_ms()
+ if not force:
+ last_saved_ms = self._last_position_persist_ms_by_user.get(client.user_id, 0)
+ if now_ms - last_saved_ms < POSITION_PERSIST_DEBOUNCE_MS:
+ return
+ self.auth_service.set_last_position(client.user_id, client.x, client.y)
+ self._last_position_persist_ms_by_user[client.user_id] = now_ms
+
def _auth_policy(self) -> dict[str, int]:
"""Return server-auth policy limits advertised to clients."""
@@ -770,6 +785,9 @@ class SignalingServer:
if websocket in self.clients:
disconnected = self.clients.pop(websocket)
self.active_piano_keys_by_client.pop(disconnected.id, None)
+ self._persist_client_position(disconnected, force=True)
+ if disconnected.user_id:
+ self._last_position_persist_ms_by_user.pop(disconnected.user_id, None)
for item_id, session in list(self.piano_recording_state_by_item.items()):
if session.get("ownerClientId") != disconnected.id:
continue
@@ -829,8 +847,14 @@ class SignalingServer:
if client.websocket in self.clients:
return
- client.x = random.randrange(self.grid_size)
- client.y = random.randrange(self.grid_size)
+ saved_x = getattr(client, "saved_x", None)
+ saved_y = getattr(client, "saved_y", None)
+ if isinstance(saved_x, int) and isinstance(saved_y, int) and self._is_in_bounds(saved_x, saved_y):
+ client.x = saved_x
+ client.y = saved_y
+ else:
+ client.x = random.randrange(self.grid_size)
+ client.y = random.randrange(self.grid_size)
now_ms = self.item_service.now_ms()
client.last_position_update_ms = now_ms
client.movement_window_index = self._movement_window_index(now_ms)
@@ -910,6 +934,8 @@ class SignalingServer:
client.role = session.user.role
client.session_token = session.token
client.nickname = session.user.last_nickname or client.nickname
+ client.saved_x = session.user.last_x
+ client.saved_y = session.user.last_y
await self._send(
client.websocket,
AuthResultPacket(
@@ -1036,6 +1062,7 @@ class SignalingServer:
client.x = packet.x
client.y = packet.y
client.last_position_update_ms = now_ms
+ self._persist_client_position(client)
await self._send(
client.websocket,
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),
@@ -1070,6 +1097,7 @@ class SignalingServer:
client.x = packet.x
client.y = packet.y
client.last_position_update_ms = self.item_service.now_ms()
+ self._persist_client_position(client, force=True)
await self._send(
client.websocket,
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),