diff --git a/lib/src/model/account/account_preferences.dart b/lib/src/model/account/account_preferences.dart
index ee6950f089..343843f1d7 100644
--- a/lib/src/model/account/account_preferences.dart
+++ b/lib/src/model/account/account_preferences.dart
@@ -1,5 +1,6 @@
 import 'package:fast_immutable_collections/fast_immutable_collections.dart';
 import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:lichess_mobile/src/model/auth/auth_session.dart';
 import 'package:lichess_mobile/src/network/http.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
@@ -29,28 +30,31 @@ typedef AccountPrefState = ({
 });
 
 /// A provider that tells if the user wants to see ratings in the app.
-final showRatingsPrefProvider = FutureProvider<bool>((ref) async {
+@Riverpod(keepAlive: true)
+Future<bool> showRatingsPref(Ref ref) async {
   return ref.watch(
     accountPreferencesProvider
         .selectAsync((state) => state?.showRatings.value ?? true),
   );
-});
+}
 
-final clockSoundProvider = FutureProvider<bool>((ref) async {
+@Riverpod(keepAlive: true)
+Future<bool> clockSound(Ref ref) async {
   return ref.watch(
     accountPreferencesProvider
         .selectAsync((state) => state?.clockSound.value ?? true),
   );
-});
+}
 
-final pieceNotationProvider = FutureProvider<PieceNotation>((ref) async {
+@Riverpod(keepAlive: true)
+Future<PieceNotation> pieceNotation(Ref ref) async {
   return ref.watch(
     accountPreferencesProvider.selectAsync(
       (state) =>
           state?.pieceNotation ?? defaultAccountPreferences.pieceNotation,
     ),
   );
-});
+}
 
 final defaultAccountPreferences = (
   zenMode: Zen.no,
diff --git a/lib/src/model/clock/chess_clock.dart b/lib/src/model/clock/chess_clock.dart
new file mode 100644
index 0000000000..b4560932e2
--- /dev/null
+++ b/lib/src/model/clock/chess_clock.dart
@@ -0,0 +1,184 @@
+import 'dart:async';
+
+import 'package:clock/clock.dart';
+import 'package:dartchess/dartchess.dart';
+import 'package:flutter/foundation.dart';
+
+const _emergencyDelay = Duration(seconds: 20);
+const _tickDelay = Duration(milliseconds: 100);
+
+/// A chess clock.
+class ChessClock {
+  ChessClock({
+    required Duration whiteTime,
+    required Duration blackTime,
+    this.emergencyThreshold,
+    this.onFlag,
+    this.onEmergency,
+  })  : _whiteTime = ValueNotifier(whiteTime),
+        _blackTime = ValueNotifier(blackTime),
+        _activeSide = Side.white;
+
+  /// The threshold at which the clock will call [onEmergency] if provided.
+  final Duration? emergencyThreshold;
+
+  /// Callback when the clock reaches zero.
+  VoidCallback? onFlag;
+
+  /// Called when one clock timers reaches the emergency threshold.
+  final void Function(Side activeSide)? onEmergency;
+
+  Timer? _timer;
+  Timer? _startDelayTimer;
+  DateTime? _lastStarted;
+  final _stopwatch = clock.stopwatch();
+  bool _shouldPlayEmergencyFeedback = true;
+  DateTime? _nextEmergency;
+
+  final ValueNotifier<Duration> _whiteTime;
+  final ValueNotifier<Duration> _blackTime;
+  Side _activeSide;
+
+  bool get isRunning {
+    return _lastStarted != null;
+  }
+
+  /// Returns the current white time.
+  ValueListenable<Duration> get whiteTime => _whiteTime;
+
+  /// Returns the current black time.
+  ValueListenable<Duration> get blackTime => _blackTime;
+
+  /// Returns the current active time.
+  ValueListenable<Duration> get activeTime => _activeTime;
+
+  /// Returns the current active side.
+  Side get activeSide => _activeSide;
+
+  /// Sets the time for either side.
+  void setTimes({Duration? whiteTime, Duration? blackTime}) {
+    if (whiteTime != null) {
+      _whiteTime.value = whiteTime;
+    }
+    if (blackTime != null) {
+      _blackTime.value = blackTime;
+    }
+  }
+
+  /// Sets the time for the given side.
+  void setTime(Side side, Duration time) {
+    if (side == Side.white) {
+      _whiteTime.value = time;
+    } else {
+      _blackTime.value = time;
+    }
+  }
+
+  /// Increments the time for either side.
+  void incTimes({Duration? whiteInc, Duration? blackInc}) {
+    if (whiteInc != null) {
+      _whiteTime.value += whiteInc;
+    }
+    if (blackInc != null) {
+      _blackTime.value += blackInc;
+    }
+  }
+
+  /// Increments the time for the given side.
+  void incTime(Side side, Duration increment) {
+    if (side == Side.white) {
+      _whiteTime.value += increment;
+    } else {
+      _blackTime.value += increment;
+    }
+  }
+
+  /// Starts the clock and switch to the given side.
+  ///
+  /// Trying to start an already running clock on the same side is a no-op.
+  ///
+  /// The [delay] parameter can be used to add a delay before the clock starts counting down. This is useful for lag compensation.
+  ///
+  /// Returns the think time of the active side before switching or `null` if the clock is not running.
+  Duration? startSide(Side side, {Duration? delay}) {
+    if (isRunning && _activeSide == side) {
+      return _thinkTime;
+    }
+    _activeSide = side;
+    final thinkTime = _thinkTime;
+    start(delay: delay);
+    return thinkTime;
+  }
+
+  /// Starts the clock.
+  ///
+  /// The [delay] parameter can be used to add a delay before the clock starts counting down. This is useful for lag compensation.
+  void start({Duration? delay}) {
+    _lastStarted = clock.now().add(delay ?? Duration.zero);
+    _startDelayTimer?.cancel();
+    _startDelayTimer = Timer(delay ?? Duration.zero, _scheduleTick);
+  }
+
+  /// Pauses the clock.
+  ///
+  /// Returns the current think time for the active side.
+  Duration stop() {
+    _stopwatch.stop();
+    _startDelayTimer?.cancel();
+    _timer?.cancel();
+    final thinkTime = _thinkTime ?? Duration.zero;
+    _lastStarted = null;
+    return thinkTime;
+  }
+
+  void dispose() {
+    _timer?.cancel();
+    _startDelayTimer?.cancel();
+    _whiteTime.dispose();
+    _blackTime.dispose();
+  }
+
+  /// Returns the current think time for the active side.
+  Duration? get _thinkTime {
+    if (_lastStarted == null) {
+      return null;
+    }
+    return clock.now().difference(_lastStarted!);
+  }
+
+  ValueNotifier<Duration> get _activeTime {
+    return activeSide == Side.white ? _whiteTime : _blackTime;
+  }
+
+  void _scheduleTick() {
+    _stopwatch.reset();
+    _stopwatch.start();
+    _timer?.cancel();
+    _timer = Timer(_tickDelay, _tick);
+  }
+
+  void _tick() {
+    final newTime = _activeTime.value - _stopwatch.elapsed;
+    _activeTime.value = newTime < Duration.zero ? Duration.zero : newTime;
+    _checkEmergency();
+    if (_activeTime.value == Duration.zero) {
+      onFlag?.call();
+    }
+    _scheduleTick();
+  }
+
+  void _checkEmergency() {
+    final timeLeft = _activeTime.value;
+    if (emergencyThreshold != null &&
+        timeLeft <= emergencyThreshold! &&
+        _shouldPlayEmergencyFeedback &&
+        (_nextEmergency == null || _nextEmergency!.isBefore(clock.now()))) {
+      _shouldPlayEmergencyFeedback = false;
+      _nextEmergency = clock.now().add(_emergencyDelay);
+      onEmergency?.call(_activeSide);
+    } else if (emergencyThreshold != null &&
+        timeLeft > emergencyThreshold! * 1.5) {
+      _shouldPlayEmergencyFeedback = true;
+    }
+  }
+}
diff --git a/lib/src/model/clock/clock_controller.dart b/lib/src/model/clock/clock_controller.dart
deleted file mode 100644
index 1a70a0ff92..0000000000
--- a/lib/src/model/clock/clock_controller.dart
+++ /dev/null
@@ -1,191 +0,0 @@
-import 'package:freezed_annotation/freezed_annotation.dart';
-import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
-import 'package:lichess_mobile/src/model/common/time_increment.dart';
-import 'package:riverpod_annotation/riverpod_annotation.dart';
-
-part 'clock_controller.freezed.dart';
-part 'clock_controller.g.dart';
-
-@riverpod
-class ClockController extends _$ClockController {
-  @override
-  ClockState build() {
-    const time = Duration(minutes: 10);
-    const increment = Duration.zero;
-    return ClockState.fromOptions(
-      const ClockOptions(
-        timePlayerTop: time,
-        timePlayerBottom: time,
-        incrementPlayerTop: increment,
-        incrementPlayerBottom: increment,
-      ),
-    );
-  }
-
-  void onTap(ClockPlayerType playerType) {
-    final started = state.started;
-    if (playerType == ClockPlayerType.top) {
-      state = state.copyWith(
-        started: true,
-        activeSide: ClockPlayerType.bottom,
-        playerTopMoves: started ? state.playerTopMoves + 1 : 0,
-      );
-    } else {
-      state = state.copyWith(
-        started: true,
-        activeSide: ClockPlayerType.top,
-        playerBottomMoves: started ? state.playerBottomMoves + 1 : 0,
-      );
-    }
-    ref.read(soundServiceProvider).play(Sound.clock);
-  }
-
-  void updateDuration(ClockPlayerType playerType, Duration duration) {
-    if (state.loser != null || state.paused) {
-      return;
-    }
-
-    if (playerType == ClockPlayerType.top) {
-      state = state.copyWith(
-        playerTopTime: duration + state.options.incrementPlayerTop,
-      );
-    } else {
-      state = state.copyWith(
-        playerBottomTime: duration + state.options.incrementPlayerBottom,
-      );
-    }
-  }
-
-  void updateOptions(TimeIncrement timeIncrement) =>
-      state = ClockState.fromTimeIncrement(timeIncrement);
-
-  void updateOptionsCustom(
-    TimeIncrement clock,
-    ClockPlayerType player,
-  ) =>
-      state = ClockState.fromOptions(
-        ClockOptions(
-          timePlayerTop: player == ClockPlayerType.top
-              ? Duration(seconds: clock.time)
-              : state.options.timePlayerTop,
-          timePlayerBottom: player == ClockPlayerType.bottom
-              ? Duration(seconds: clock.time)
-              : state.options.timePlayerBottom,
-          incrementPlayerTop: player == ClockPlayerType.top
-              ? Duration(seconds: clock.increment)
-              : state.options.incrementPlayerTop,
-          incrementPlayerBottom: player == ClockPlayerType.bottom
-              ? Duration(seconds: clock.increment)
-              : state.options.incrementPlayerBottom,
-        ),
-      );
-
-  void setActiveSide(ClockPlayerType playerType) =>
-      state = state.copyWith(activeSide: playerType);
-
-  void setLoser(ClockPlayerType playerType) =>
-      state = state.copyWith(loser: playerType);
-
-  void reset() => state = ClockState.fromOptions(state.options);
-
-  void start() => state = state.copyWith(started: true);
-
-  void pause() => state = state.copyWith(paused: true);
-
-  void resume() => state = state.copyWith(paused: false);
-}
-
-enum ClockPlayerType { top, bottom }
-
-@freezed
-class ClockOptions with _$ClockOptions {
-  const ClockOptions._();
-
-  const factory ClockOptions({
-    required Duration timePlayerTop,
-    required Duration timePlayerBottom,
-    required Duration incrementPlayerTop,
-    required Duration incrementPlayerBottom,
-  }) = _ClockOptions;
-}
-
-@freezed
-class ClockState with _$ClockState {
-  const ClockState._();
-
-  const factory ClockState({
-    required int id,
-    required ClockOptions options,
-    required Duration playerTopTime,
-    required Duration playerBottomTime,
-    required ClockPlayerType activeSide,
-    ClockPlayerType? loser,
-    @Default(false) bool started,
-    @Default(false) bool paused,
-    @Default(0) int playerTopMoves,
-    @Default(0) int playerBottomMoves,
-  }) = _ClockState;
-
-  factory ClockState.fromTimeIncrement(TimeIncrement timeIncrement) {
-    final options = ClockOptions(
-      timePlayerTop: Duration(seconds: timeIncrement.time),
-      timePlayerBottom: Duration(seconds: timeIncrement.time),
-      incrementPlayerTop: Duration(seconds: timeIncrement.increment),
-      incrementPlayerBottom: Duration(seconds: timeIncrement.increment),
-    );
-
-    return ClockState(
-      id: DateTime.now().millisecondsSinceEpoch,
-      options: options,
-      activeSide: ClockPlayerType.top,
-      playerTopTime: options.timePlayerTop,
-      playerBottomTime: options.timePlayerBottom,
-    );
-  }
-
-  factory ClockState.fromSeparateTimeIncrements(
-    TimeIncrement playerTop,
-    TimeIncrement playerBottom,
-  ) {
-    final options = ClockOptions(
-      timePlayerTop: Duration(seconds: playerTop.time),
-      timePlayerBottom: Duration(seconds: playerBottom.time),
-      incrementPlayerTop: Duration(seconds: playerTop.increment),
-      incrementPlayerBottom: Duration(seconds: playerBottom.increment),
-    );
-    return ClockState(
-      id: DateTime.now().millisecondsSinceEpoch,
-      activeSide: ClockPlayerType.top,
-      options: options,
-      playerTopTime: options.timePlayerTop,
-      playerBottomTime: options.timePlayerBottom,
-    );
-  }
-
-  factory ClockState.fromOptions(ClockOptions options) {
-    return ClockState(
-      id: DateTime.now().millisecondsSinceEpoch,
-      activeSide: ClockPlayerType.top,
-      options: options,
-      playerTopTime: options.timePlayerTop,
-      playerBottomTime: options.timePlayerBottom,
-    );
-  }
-
-  Duration getDuration(ClockPlayerType playerType) =>
-      playerType == ClockPlayerType.top ? playerTopTime : playerBottomTime;
-
-  int getMovesCount(ClockPlayerType playerType) =>
-      playerType == ClockPlayerType.top ? playerTopMoves : playerBottomMoves;
-
-  bool isPlayersTurn(ClockPlayerType playerType) =>
-      started && activeSide == playerType && loser == null;
-
-  bool isPlayersMoveAllowed(ClockPlayerType playerType) =>
-      isPlayersTurn(playerType) && !paused;
-
-  bool isActivePlayer(ClockPlayerType playerType) =>
-      isPlayersTurn(playerType) && !paused;
-
-  bool isLoser(ClockPlayerType playerType) => loser == playerType;
-}
diff --git a/lib/src/model/clock/clock_tool_controller.dart b/lib/src/model/clock/clock_tool_controller.dart
new file mode 100644
index 0000000000..524b543a9d
--- /dev/null
+++ b/lib/src/model/clock/clock_tool_controller.dart
@@ -0,0 +1,231 @@
+import 'package:dartchess/dartchess.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:lichess_mobile/src/model/clock/chess_clock.dart';
+import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
+import 'package:lichess_mobile/src/model/common/time_increment.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+part 'clock_tool_controller.freezed.dart';
+part 'clock_tool_controller.g.dart';
+
+@riverpod
+class ClockToolController extends _$ClockToolController {
+  late final ChessClock _clock;
+
+  @override
+  ClockState build() {
+    const time = Duration(minutes: 10);
+    const increment = Duration.zero;
+    const options = ClockOptions(
+      whiteTime: time,
+      blackTime: time,
+      whiteIncrement: increment,
+      blackIncrement: increment,
+    );
+    _clock = ChessClock(
+      whiteTime: time,
+      blackTime: time,
+      onFlag: _onFlagged,
+    );
+
+    ref.onDispose(() {
+      _clock.dispose();
+    });
+
+    return ClockState(
+      options: options,
+      whiteTime: _clock.whiteTime,
+      blackTime: _clock.blackTime,
+      activeSide: Side.white,
+    );
+  }
+
+  void _onFlagged() {
+    _clock.stop();
+    state = state.copyWith(flagged: _clock.activeSide);
+  }
+
+  void onTap(Side playerType) {
+    final started = state.started;
+    if (playerType == Side.white) {
+      state = state.copyWith(
+        started: true,
+        activeSide: Side.black,
+        whiteMoves: started ? state.whiteMoves + 1 : 0,
+      );
+    } else {
+      state = state.copyWith(
+        started: true,
+        activeSide: Side.white,
+        blackMoves: started ? state.blackMoves + 1 : 0,
+      );
+    }
+    ref.read(soundServiceProvider).play(Sound.clock);
+    _clock.startSide(playerType.opposite);
+    _clock.incTime(
+      playerType,
+      playerType == Side.white
+          ? state.options.whiteIncrement
+          : state.options.blackIncrement,
+    );
+  }
+
+  void updateDuration(Side playerType, Duration duration) {
+    if (state.flagged != null || state.paused) {
+      return;
+    }
+
+    _clock.setTimes(
+      whiteTime: playerType == Side.white
+          ? duration + state.options.whiteIncrement
+          : null,
+      blackTime: playerType == Side.black
+          ? duration + state.options.blackIncrement
+          : null,
+    );
+  }
+
+  void updateOptions(TimeIncrement timeIncrement) {
+    final options = ClockOptions.fromTimeIncrement(timeIncrement);
+    _clock.setTimes(
+      whiteTime: options.whiteTime,
+      blackTime: options.blackTime,
+    );
+    state = state.copyWith(
+      options: options,
+      whiteTime: _clock.whiteTime,
+      blackTime: _clock.blackTime,
+    );
+  }
+
+  void updateOptionsCustom(
+    TimeIncrement clock,
+    Side player,
+  ) {
+    final options = ClockOptions(
+      whiteTime: player == Side.white
+          ? Duration(seconds: clock.time)
+          : state.options.whiteTime,
+      blackTime: player == Side.black
+          ? Duration(seconds: clock.time)
+          : state.options.blackTime,
+      whiteIncrement: player == Side.white
+          ? Duration(seconds: clock.increment)
+          : state.options.whiteIncrement,
+      blackIncrement: player == Side.black
+          ? Duration(seconds: clock.increment)
+          : state.options.blackIncrement,
+    );
+    _clock.setTimes(
+      whiteTime: options.whiteTime,
+      blackTime: options.blackTime,
+    );
+    state = ClockState(
+      options: options,
+      whiteTime: _clock.whiteTime,
+      blackTime: _clock.blackTime,
+      activeSide: state.activeSide,
+    );
+  }
+
+  void setBottomPlayer(Side playerType) =>
+      state = state.copyWith(bottomPlayer: playerType);
+
+  void reset() {
+    _clock.setTimes(
+      whiteTime: state.options.whiteTime,
+      blackTime: state.options.whiteTime,
+    );
+    state = state.copyWith(
+      whiteTime: _clock.whiteTime,
+      blackTime: _clock.blackTime,
+      activeSide: Side.white,
+      flagged: null,
+      started: false,
+      paused: false,
+      whiteMoves: 0,
+      blackMoves: 0,
+    );
+  }
+
+  void start() {
+    _clock.start();
+    state = state.copyWith(started: true);
+  }
+
+  void pause() {
+    _clock.stop();
+    state = state.copyWith(paused: true);
+  }
+
+  void resume() {
+    _clock.start();
+    state = state.copyWith(paused: false);
+  }
+}
+
+@freezed
+class ClockOptions with _$ClockOptions {
+  const ClockOptions._();
+
+  const factory ClockOptions({
+    required Duration whiteTime,
+    required Duration blackTime,
+    required Duration whiteIncrement,
+    required Duration blackIncrement,
+  }) = _ClockOptions;
+
+  factory ClockOptions.fromTimeIncrement(TimeIncrement timeIncrement) =>
+      ClockOptions(
+        whiteTime: Duration(seconds: timeIncrement.time),
+        blackTime: Duration(seconds: timeIncrement.time),
+        whiteIncrement: Duration(seconds: timeIncrement.increment),
+        blackIncrement: Duration(seconds: timeIncrement.increment),
+      );
+
+  factory ClockOptions.fromSeparateTimeIncrements(
+    TimeIncrement playerTop,
+    TimeIncrement playerBottom,
+  ) =>
+      ClockOptions(
+        whiteTime: Duration(seconds: playerTop.time),
+        blackTime: Duration(seconds: playerBottom.time),
+        whiteIncrement: Duration(seconds: playerTop.increment),
+        blackIncrement: Duration(seconds: playerBottom.increment),
+      );
+}
+
+@freezed
+class ClockState with _$ClockState {
+  const ClockState._();
+
+  const factory ClockState({
+    required ClockOptions options,
+    required ValueListenable<Duration> whiteTime,
+    required ValueListenable<Duration> blackTime,
+    required Side activeSide,
+    @Default(Side.white) Side bottomPlayer,
+    Side? flagged,
+    @Default(false) bool started,
+    @Default(false) bool paused,
+    @Default(0) int whiteMoves,
+    @Default(0) int blackMoves,
+  }) = _ClockState;
+
+  ValueListenable<Duration> getDuration(Side playerType) =>
+      playerType == Side.white ? whiteTime : blackTime;
+
+  int getMovesCount(Side playerType) =>
+      playerType == Side.white ? whiteMoves : blackMoves;
+
+  bool isPlayersTurn(Side playerType) =>
+      started && activeSide == playerType && flagged == null;
+
+  bool isPlayersMoveAllowed(Side playerType) =>
+      isPlayersTurn(playerType) && !paused;
+
+  bool isActivePlayer(Side playerType) => isPlayersTurn(playerType) && !paused;
+
+  bool isFlagged(Side playerType) => flagged == playerType;
+}
diff --git a/lib/src/model/correspondence/offline_correspondence_game.dart b/lib/src/model/correspondence/offline_correspondence_game.dart
index 1c8fc0cf5a..d7bcb1599f 100644
--- a/lib/src/model/correspondence/offline_correspondence_game.dart
+++ b/lib/src/model/correspondence/offline_correspondence_game.dart
@@ -72,7 +72,6 @@ class OfflineCorrespondenceGame
     return null;
   }
 
-  bool get isPlayerTurn => lastPosition.turn == youAre;
   bool get playable => status.value < GameStatus.aborted.value;
   bool get playing => status.value > GameStatus.started.value;
   bool get finished => status.value >= GameStatus.mate.value;
diff --git a/lib/src/model/game/chat_controller.dart b/lib/src/model/game/chat_controller.dart
index 6ec13b78a6..8c0f294945 100644
--- a/lib/src/model/game/chat_controller.dart
+++ b/lib/src/model/game/chat_controller.dart
@@ -125,14 +125,8 @@ class ChatController extends _$ChatController {
       }
     } else if (event.topic == 'message') {
       final data = event.data as Map<String, dynamic>;
-      final message = data['t'] as String;
-      final username = data['u'] as String?;
-      _addMessage(
-        (
-          message: message,
-          username: username,
-        ),
-      );
+      final message = _messageFromPick(RequiredPick(data));
+      _addMessage(message);
     }
   }
 }
@@ -147,11 +141,58 @@ class ChatState with _$ChatState {
   }) = _ChatState;
 }
 
-typedef Message = ({String? username, String message});
+typedef Message = ({
+  String? username,
+  String message,
+  bool troll,
+  bool deleted,
+});
 
 Message _messageFromPick(RequiredPick pick) {
   return (
     message: pick('t').asStringOrThrow(),
     username: pick('u').asStringOrNull(),
+    troll: pick('r').asBoolOrNull() ?? false,
+    deleted: pick('d').asBoolOrNull() ?? false,
   );
 }
+
+bool isSpam(Message message) {
+  return spamRegex.hasMatch(message.message) ||
+      followMeRegex.hasMatch(message.message);
+}
+
+final RegExp spamRegex = RegExp(
+  [
+    'xcamweb.com',
+    '(^|[^i])chess-bot',
+    'chess-cheat',
+    'coolteenbitch',
+    'letcafa.webcam',
+    'tinyurl.com/',
+    'wooga.info/',
+    'bit.ly/',
+    'wbt.link/',
+    'eb.by/',
+    '001.rs/',
+    'shr.name/',
+    'u.to/',
+    '.3-a.net',
+    '.ssl443.org',
+    '.ns02.us',
+    '.myftp.info',
+    '.flinkup.com',
+    '.serveusers.com',
+    'badoogirls.com',
+    'hide.su',
+    'wyon.de',
+    'sexdatingcz.club',
+    'qps.ru',
+    'tiny.cc/',
+    'trasderk.blogspot.com',
+    't.ly/',
+    'shorturl.at/',
+  ].map((url) => url.replaceAll('.', '\\.').replaceAll('/', '\\/')).join('|'),
+);
+
+final followMeRegex = RegExp('follow me|join my team', caseSensitive: false);
diff --git a/lib/src/model/game/game.dart b/lib/src/model/game/game.dart
index 13d11db993..5e0a3750fe 100644
--- a/lib/src/model/game/game.dart
+++ b/lib/src/model/game/game.dart
@@ -319,15 +319,6 @@ class GameMeta with _$GameMeta {
       _$GameMetaFromJson(json);
 }
 
-@freezed
-class PlayableClockData with _$PlayableClockData {
-  const factory PlayableClockData({
-    required bool running,
-    required Duration white,
-    required Duration black,
-  }) = _PlayableClockData;
-}
-
 @Freezed(fromJson: true, toJson: true)
 class CorrespondenceClockData with _$CorrespondenceClockData {
   const factory CorrespondenceClockData({
diff --git a/lib/src/model/game/game_controller.dart b/lib/src/model/game/game_controller.dart
index d2a4ac3791..51a8cbb023 100644
--- a/lib/src/model/game/game_controller.dart
+++ b/lib/src/model/game/game_controller.dart
@@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
 import 'package:dartchess/dartchess.dart';
 import 'package:deep_pick/deep_pick.dart';
 import 'package:fast_immutable_collections/fast_immutable_collections.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
@@ -12,6 +13,7 @@ import 'package:lichess_mobile/src/model/account/account_preferences.dart';
 import 'package:lichess_mobile/src/model/account/account_repository.dart';
 import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
 import 'package:lichess_mobile/src/model/analysis/server_analysis_service.dart';
+import 'package:lichess_mobile/src/model/clock/chess_clock.dart';
 import 'package:lichess_mobile/src/model/common/chess.dart';
 import 'package:lichess_mobile/src/model/common/id.dart';
 import 'package:lichess_mobile/src/model/common/service/move_feedback.dart';
@@ -62,14 +64,12 @@ class GameController extends _$GameController {
   /// Last socket version received
   int? _socketEventVersion;
 
-  /// Last move time
-  DateTime? _lastMoveTime;
-
-  late SocketClient _socketClient;
-
   static Uri gameSocketUri(GameFullId gameFullId) =>
       Uri(path: '/play/$gameFullId/v6');
 
+  ChessClock? _clock;
+  late final SocketClient _socketClient;
+
   @override
   Future<GameState> build(GameFullId gameFullId) {
     final socketPool = ref.watch(socketPoolProvider);
@@ -85,6 +85,7 @@ class GameController extends _$GameController {
       _opponentLeftCountdownTimer?.cancel();
       _transientMoveTimer?.cancel();
       _appLifecycleListener?.dispose();
+      _clock?.dispose();
     });
 
     return _socketClient.stream.firstWhere((e) => e.topic == 'full').then(
@@ -113,6 +114,13 @@ class GameController extends _$GameController {
 
         _socketEventVersion = fullEvent.socketEventVersion;
 
+        // Play "dong" sound when this is a new game and we're playing it (not spectating)
+        final isMyGame = game.youAre != null;
+        final noMovePlayed = game.steps.length == 1;
+        if (isMyGame && noMovePlayed && game.status == GameStatus.started) {
+          ref.read(soundServiceProvider).play(Sound.dong);
+        }
+
         if (game.playable) {
           _appLifecycleListener = AppLifecycleListener(
             onResume: () {
@@ -123,13 +131,29 @@ class GameController extends _$GameController {
               }
             },
           );
+
+          if (game.clock != null) {
+            _clock = ChessClock(
+              whiteTime: game.clock!.white,
+              blackTime: game.clock!.black,
+              emergencyThreshold: game.meta.clock?.emergency,
+              onEmergency: onClockEmergency,
+              onFlag: onFlag,
+            );
+            if (game.clock!.running) {
+              final pos = game.lastPosition;
+              if (pos.fullmoves > 1) {
+                _clock!.startSide(pos.turn);
+              }
+            }
+          }
         }
 
         return GameState(
           gameFullId: gameFullId,
           game: game,
           stepCursor: game.steps.length - 1,
-          stopClockWaitingForServerAck: false,
+          liveClock: _liveClock,
         );
       },
     );
@@ -161,7 +185,6 @@ class GameController extends _$GameController {
           steps: curState.game.steps.add(newStep),
         ),
         stepCursor: curState.stepCursor + 1,
-        stopClockWaitingForServerAck: !shouldConfirmMove,
         moveToConfirm: shouldConfirmMove ? move : null,
         promotionMove: null,
         premove: null,
@@ -174,7 +197,6 @@ class GameController extends _$GameController {
       _sendMoveToSocket(
         move,
         isPremove: isPremove ?? false,
-        hasClock: curState.game.clock != null,
         // same logic as web client
         // we want to send client lag only at the beginning of the game when the clock is not running yet
         withLag:
@@ -229,14 +251,12 @@ class GameController extends _$GameController {
 
     state = AsyncValue.data(
       curState.copyWith(
-        stopClockWaitingForServerAck: true,
         moveToConfirm: null,
       ),
     );
     _sendMoveToSocket(
       moveToConfirm,
       isPremove: false,
-      hasClock: curState.game.clock != null,
       // same logic as web client
       // we want to send client lag only at the beginning of the game when the clock is not running yet
       withLag: curState.game.clock != null && curState.activeClockSide == null,
@@ -334,6 +354,15 @@ class GameController extends _$GameController {
     }
   }
 
+  /// Play a sound when the clock is about to run out
+  Future<void> onClockEmergency(Side activeSide) async {
+    if (activeSide != state.valueOrNull?.game.youAre) return;
+    final shouldPlay = await ref.read(clockSoundProvider.future);
+    if (shouldPlay) {
+      ref.read(soundServiceProvider).play(Sound.lowTime);
+    }
+  }
+
   void onFlag() {
     _onFlagThrottler(() {
       if (state.hasValue) {
@@ -410,18 +439,39 @@ class GameController extends _$GameController {
         Future<void>.value();
   }
 
+  /// Gets the live game clock if available.
+  LiveGameClock? get _liveClock => _clock != null
+      ? (
+          white: _clock!.whiteTime,
+          black: _clock!.blackTime,
+        )
+      : null;
+
+  /// Update the internal clock on clock server event
+  void _updateClock({
+    required Duration white,
+    required Duration black,
+    required Side? activeSide,
+    Duration? lag,
+  }) {
+    _clock?.setTimes(whiteTime: white, blackTime: black);
+    if (activeSide != null) {
+      _clock?.startSide(activeSide, delay: lag);
+    } else {
+      _clock?.stop();
+    }
+  }
+
   void _sendMoveToSocket(
     Move move, {
     required bool isPremove,
-    required bool hasClock,
     required bool withLag,
   }) {
-    final moveTime = hasClock
+    final thinkTime = _clock?.stop();
+    final moveTime = _clock != null
         ? isPremove == true
             ? Duration.zero
-            : _lastMoveTime != null
-                ? DateTime.now().difference(_lastMoveTime!)
-                : null
+            : thinkTime
         : null;
     _socketClient.send(
       'move',
@@ -431,7 +481,7 @@ class GameController extends _$GameController {
           's': (moveTime.inMilliseconds * 0.1).round().toRadixString(36),
       },
       ackable: true,
-      withLag: hasClock && (moveTime == null || withLag),
+      withLag: _clock != null && (moveTime == null || withLag),
     );
 
     _transientMoveTimer = Timer(const Duration(seconds: 10), _resyncGameData);
@@ -542,20 +592,27 @@ class GameController extends _$GameController {
           return;
         }
         _socketEventVersion = fullEvent.socketEventVersion;
-        _lastMoveTime = null;
 
         state = AsyncValue.data(
           GameState(
             gameFullId: gameFullId,
             game: fullEvent.game,
             stepCursor: fullEvent.game.steps.length - 1,
-            stopClockWaitingForServerAck: false,
+            liveClock: _liveClock,
             // cancel the premove to avoid playing wrong premove when the full
             // game data is reloaded
             premove: null,
           ),
         );
 
+        if (fullEvent.game.clock != null) {
+          _updateClock(
+            white: fullEvent.game.clock!.white,
+            black: fullEvent.game.clock!.black,
+            activeSide: state.requireValue.activeClockSide,
+          );
+        }
+
       // Move event, received after sending a move or receiving a move from the
       // opponent
       case 'move':
@@ -602,11 +659,24 @@ class GameController extends _$GameController {
           }
         }
 
-        // TODO handle delay
         if (data.clock != null) {
-          _lastMoveTime = DateTime.now();
+          final lag = newState.game.playable && newState.game.isMyTurn
+              // my own clock doesn't need to be compensated for
+              ? Duration.zero
+              // server will send the lag only if it's more than 10ms
+              // default lag of 10ms is also used by web client
+              : data.clock?.lag ?? const Duration(milliseconds: 10);
+
+          _updateClock(
+            white: data.clock!.white,
+            black: data.clock!.black,
+            lag: lag,
+            activeSide: newState.activeClockSide,
+          );
 
           if (newState.game.clock != null) {
+            // we don't rely on these values to display the clock, but let's keep
+            // the game object in sync
             newState = newState.copyWith.game.clock!(
               white: data.clock!.white,
               black: data.clock!.black,
@@ -617,10 +687,6 @@ class GameController extends _$GameController {
               black: data.clock!.black,
             );
           }
-
-          newState = newState.copyWith(
-            stopClockWaitingForServerAck: false,
-          );
         }
 
         if (newState.game.expiration != null) {
@@ -686,6 +752,11 @@ class GameController extends _$GameController {
             white: endData.clock!.white,
             black: endData.clock!.black,
           );
+          _updateClock(
+            white: endData.clock!.white,
+            black: endData.clock!.black,
+            activeSide: newState.activeClockSide,
+          );
         }
 
         if (curState.game.lastPosition.fullmoves > 1) {
@@ -723,7 +794,11 @@ class GameController extends _$GameController {
         final newClock = pick(data['total'])
             .letOrNull((it) => Duration(milliseconds: it.asIntOrThrow() * 10));
         final curState = state.requireValue;
+
         if (side != null && newClock != null) {
+          _clock?.setTime(side, newClock);
+
+          // sync game clock object even if it's not used to display the clock
           final newState = side == Side.white
               ? curState.copyWith.game.clock!(
                   white: newClock,
@@ -978,6 +1053,11 @@ class GameController extends _$GameController {
   }
 }
 
+typedef LiveGameClock = ({
+  ValueListenable<Duration> white,
+  ValueListenable<Duration> black,
+});
+
 @freezed
 class GameState with _$GameState {
   const GameState._();
@@ -986,9 +1066,9 @@ class GameState with _$GameState {
     required GameFullId gameFullId,
     required PlayableGame game,
     required int stepCursor,
+    required LiveGameClock? liveClock,
     int? lastDrawOfferAtPly,
     Duration? opponentLeftCountdown,
-    required bool stopClockWaitingForServerAck,
 
     /// Promotion waiting to be selected (only if auto queen is disabled)
     NormalMove? promotionMove,
@@ -1057,7 +1137,7 @@ class GameState with _$GameState {
       game.drawable && (lastDrawOfferAtPly ?? -99) < game.lastPly - 20;
 
   bool get canShowClaimWinCountdown =>
-      !game.isPlayerTurn &&
+      !game.isMyTurn &&
       game.resignable &&
       (game.meta.rules == null ||
           !game.meta.rules!.contains(GameRule.noClaimWin));
@@ -1090,9 +1170,6 @@ class GameState with _$GameState {
     if (game.clock == null && game.correspondenceClock == null) {
       return null;
     }
-    if (stopClockWaitingForServerAck) {
-      return null;
-    }
     if (game.status == GameStatus.started) {
       final pos = game.lastPosition;
       if (pos.fullmoves > 1) {
diff --git a/lib/src/model/game/game_socket_events.dart b/lib/src/model/game/game_socket_events.dart
index df44964a89..cfd8dd10e8 100644
--- a/lib/src/model/game/game_socket_events.dart
+++ b/lib/src/model/game/game_socket_events.dart
@@ -1,3 +1,4 @@
+import 'package:clock/clock.dart';
 import 'package:dartchess/dartchess.dart';
 import 'package:deep_pick/deep_pick.dart';
 import 'package:fast_immutable_collections/fast_immutable_collections.dart';
@@ -40,7 +41,12 @@ class MoveEvent with _$MoveEvent {
     bool? blackOfferingDraw,
     GameStatus? status,
     Side? winner,
-    ({Duration white, Duration black, Duration? lag})? clock,
+    ({
+      Duration white,
+      Duration black,
+      Duration? lag,
+      DateTime at,
+    })? clock,
   }) = _MoveEvent;
 
   factory MoveEvent.fromJson(Map<String, dynamic> json) =>
@@ -78,6 +84,7 @@ MoveEvent _socketMoveEventFromPick(RequiredPick pick) {
     blackOfferingDraw: pick('bDraw').asBoolOrNull(),
     clock: pick('clock').letOrNull(
       (it) => (
+        at: clock.now(),
         white: it('white').asDurationFromSecondsOrThrow(),
         black: it('black').asDurationFromSecondsOrThrow(),
         lag: it('lag')
diff --git a/lib/src/model/game/game_status.dart b/lib/src/model/game/game_status.dart
index 58047c6aac..b091550940 100644
--- a/lib/src/model/game/game_status.dart
+++ b/lib/src/model/game/game_status.dart
@@ -2,19 +2,34 @@ import 'package:deep_pick/deep_pick.dart';
 import 'package:fast_immutable_collections/fast_immutable_collections.dart';
 
 enum GameStatus {
+  /// Unknown game status (not handled by the app).
   unknown(-1),
+
+  /// The game is created but not started yet.
   created(10),
   started(20),
+
+  /// From here on, the game is finished.
   aborted(25),
   mate(30),
   resign(31),
   stalemate(32),
+
+  /// When a player leaves the game.
   timeout(33),
   draw(34),
+
+  /// When a player runs out of time (clock flags).
   outoftime(35),
   cheat(36),
+
+  /// The player did not make the first move in time.
   noStart(37),
+
+  /// We don't know why the game ended.
   unknownFinish(38),
+
+  /// Chess variant special endings.
   variantEnd(60);
 
   static final nameMap = IMap(GameStatus.values.asNameMap());
diff --git a/lib/src/model/game/playable_game.dart b/lib/src/model/game/playable_game.dart
index 1e23968787..e0ae58fc0f 100644
--- a/lib/src/model/game/playable_game.dart
+++ b/lib/src/model/game/playable_game.dart
@@ -95,7 +95,8 @@ class PlayableGame
 
   bool get imported => source == GameSource.import;
 
-  bool get isPlayerTurn => lastPosition.turn == youAre;
+  /// Whether it is the current player's turn.
+  bool get isMyTurn => lastPosition.turn == youAre;
 
   /// Whether the game is properly finished (not aborted).
   bool get finished => status.value >= GameStatus.mate.value;
@@ -125,7 +126,7 @@ class PlayableGame
 
   bool get canClaimWin =>
       opponent?.isGone == true &&
-      !isPlayerTurn &&
+      !isMyTurn &&
       resignable &&
       (meta.rules == null || !meta.rules!.contains(GameRule.noClaimWin));
 
@@ -171,6 +172,23 @@ class PlayableGame
   }
 }
 
+@freezed
+class PlayableClockData with _$PlayableClockData {
+  const factory PlayableClockData({
+    required bool running,
+    required Duration white,
+    required Duration black,
+
+    /// The network lag of the clock.
+    ///
+    /// Will be sent along with move events.
+    required Duration? lag,
+
+    /// The time when the clock event was received.
+    required DateTime at,
+  }) = _PlayableClockData;
+}
+
 PlayableGame _playableGameFromPick(RequiredPick pick) {
   final requiredGamePick = pick('game').required();
   final meta = _playableGameMetaFromPick(pick);
@@ -296,6 +314,10 @@ PlayableClockData _playableClockDataFromPick(RequiredPick pick) {
     running: pick('running').asBoolOrThrow(),
     white: pick('white').asDurationFromSecondsOrThrow(),
     black: pick('black').asDurationFromSecondsOrThrow(),
+    lag: pick('lag').letOrNull(
+      (it) => Duration(milliseconds: it.asIntOrThrow() * 10),
+    ),
+    at: DateTime.now(),
   );
 }
 
diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart
index e07555b201..cfc8d8edcc 100644
--- a/lib/src/model/settings/board_preferences.dart
+++ b/lib/src/model/settings/board_preferences.dart
@@ -82,11 +82,18 @@ class BoardPreferences extends _$BoardPreferences
     );
   }
 
+    Future<void> setDragTargetKind(DragTargetKind dragTargetKind) {
+    return save(state.copyWith(dragTargetKind: dragTargetKind));
+  }
+  
   Future<void> setMaterialDifferenceFormat(
-    MaterialDifferenceFormat materialDifferenceFormat,
-  ) {
+    MaterialDifferenceFormat materialDifferenceFormat) {
+      return save(state.copyWith(materialDifferenceFormat: materialDifferenceFormat));
+  }
+
+  Future<void> setClockPosition(ClockPosition clockPosition) {
     return save(
-      state.copyWith(materialDifferenceFormat: materialDifferenceFormat),
+      state.copyWith(clockPosition: clockPosition),
     );
   }
 
@@ -115,6 +122,8 @@ class BoardPrefs with _$BoardPrefs implements Serializable {
     required bool coordinates,
     required bool pieceAnimation,
     required MaterialDifferenceFormat materialDifferenceFormat,
+    required ClockPosition clockPosition,
+
     @JsonKey(
       defaultValue: PieceShiftMethod.either,
       unknownEnumValue: PieceShiftMethod.either,
@@ -124,6 +133,8 @@ class BoardPrefs with _$BoardPrefs implements Serializable {
     /// Whether to enable shape drawings on the board for games and puzzles.
     @JsonKey(defaultValue: true) required bool enableShapeDrawings,
     @JsonKey(defaultValue: true) required bool magnifyDraggedPiece,
+    @JsonKey(defaultValue: DragTargetKind.circle)
+    required DragTargetKind dragTargetKind,
     @JsonKey(
       defaultValue: ShapeColor.green,
       unknownEnumValue: ShapeColor.green,
@@ -142,9 +153,11 @@ class BoardPrefs with _$BoardPrefs implements Serializable {
     coordinates: true,
     pieceAnimation: true,
     materialDifferenceFormat: MaterialDifferenceFormat.materialDifference,
+    clockPosition: ClockPosition.right,
     pieceShiftMethod: PieceShiftMethod.either,
     enableShapeDrawings: true,
     magnifyDraggedPiece: true,
+    dragTargetKind: DragTargetKind.circle,
     shapeColor: ShapeColor.green,
     showBorder: false,
   );
@@ -165,6 +178,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable {
       animationDuration: pieceAnimationDuration,
       dragFeedbackScale: magnifyDraggedPiece ? 2.0 : 1.0,
       dragFeedbackOffset: Offset(0.0, magnifyDraggedPiece ? -1.0 : 0.0),
+      dragTargetKind: dragTargetKind,
       pieceShiftMethod: pieceShiftMethod,
       drawShape: DrawShapeOptions(
         enable: enableShapeDrawings,
@@ -325,3 +339,21 @@ enum MaterialDifferenceFormat {
         MaterialDifferenceFormat.hidden => hidden.label,
       };
 }
+
+enum ClockPosition {
+  left,
+  right;
+
+  // TODO: l10n
+  String get label => switch (this) {
+        ClockPosition.left => 'Left',
+        ClockPosition.right => 'Right',
+      };
+}
+
+String dragTargetKindLabel(DragTargetKind kind) => switch (kind) {
+      DragTargetKind.circle => 'Circle',
+      DragTargetKind.square => 'Square',
+      DragTargetKind.none => 'None',
+    }
+;
diff --git a/lib/src/model/study/study_controller.dart b/lib/src/model/study/study_controller.dart
index 320faf2954..1337dff19c 100644
--- a/lib/src/model/study/study_controller.dart
+++ b/lib/src/model/study/study_controller.dart
@@ -33,11 +33,14 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
 
   Timer? _startEngineEvalTimer;
 
+  Timer? _opponentFirstMoveTimer;
+
   @override
   Future<StudyState> build(StudyId id) async {
     final evaluationService = ref.watch(evaluationServiceProvider);
     ref.onDispose(() {
       _startEngineEvalTimer?.cancel();
+      _opponentFirstMoveTimer?.cancel();
       _engineEvalDebounce.dispose();
       evaluationService.disposeEngine();
     });
@@ -62,6 +65,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
         chapterId: chapterId,
       ),
     );
+    _ensureItsOurTurnIfGamebook();
   }
 
   Future<StudyState> _fetchChapter(
@@ -95,6 +99,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
         pov: orientation,
         isLocalEvaluationAllowed: false,
         isLocalEvaluationEnabled: false,
+        gamebookActive: false,
         pgn: pgn,
       );
     }
@@ -119,6 +124,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
       isLocalEvaluationAllowed:
           study.chapter.features.computer && !study.chapter.gamebook,
       isLocalEvaluationEnabled: prefs.enableLocalEvaluation,
+      gamebookActive: study.chapter.gamebook,
       pgn: pgn,
     );
 
@@ -144,6 +150,19 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
     return studyState;
   }
 
+  // The PGNs of some gamebook studies start with the opponent's turn, so trigger their move after a delay
+  void _ensureItsOurTurnIfGamebook() {
+    _opponentFirstMoveTimer?.cancel();
+    if (state.requireValue.isAtStartOfChapter &&
+        state.requireValue.gamebookActive &&
+        state.requireValue.gamebookComment == null &&
+        state.requireValue.position!.turn != state.requireValue.pov) {
+      _opponentFirstMoveTimer = Timer(const Duration(milliseconds: 750), () {
+        userNext();
+      });
+    }
+  }
+
   EvaluationContext _evaluationContext(Variant variant) => EvaluationContext(
         variant: variant,
         initialPosition: _root.position,
@@ -168,6 +187,20 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
         shouldForceShowVariation: true,
       );
     }
+
+    if (state.requireValue.gamebookActive) {
+      final comment = state.requireValue.gamebookComment;
+      // If there's no explicit comment why the move was good/bad, trigger next/previous move automatically
+      if (comment == null) {
+        Timer(const Duration(milliseconds: 750), () {
+          if (state.requireValue.isOnMainline) {
+            userNext();
+          } else {
+            userPrevious();
+          }
+        });
+      }
+    }
   }
 
   void onPromotionSelection(Role? role) {
@@ -237,6 +270,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
   void reset() {
     if (state.hasValue) {
       _setPath(UciPath.empty);
+      _ensureItsOurTurnIfGamebook();
     }
   }
 
@@ -486,6 +520,14 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
   }
 }
 
+enum GamebookState {
+  startLesson,
+  findTheMove,
+  correctMove,
+  incorrectMove,
+  lessonComplete
+}
+
 @freezed
 class StudyState with _$StudyState {
   const StudyState._();
@@ -519,6 +561,9 @@ class StudyState with _$StudyState {
     /// Whether local evaluation is allowed for this study.
     required bool isLocalEvaluationAllowed,
 
+    /// Whether we're currently in gamebook mode, where the user has to find the right moves.
+    required bool gamebookActive,
+
     /// Whether the user has enabled local evaluation.
     required bool isLocalEvaluationEnabled,
 
@@ -567,6 +612,37 @@ class StudyState with _$StudyState {
 
   bool get isAtStartOfChapter => currentPath.isEmpty;
 
+  String? get gamebookComment {
+    final comment =
+        (currentNode.isRoot ? pgnRootComments : currentNode.comments)
+            ?.map((comment) => comment.text)
+            .nonNulls
+            .join('\n');
+    return comment?.isNotEmpty == true
+        ? comment
+        : gamebookState == GamebookState.incorrectMove
+            ? gamebookDeviationComment
+            : null;
+  }
+
+  String? get gamebookHint => study.hints.getOrNull(currentPath.size);
+
+  String? get gamebookDeviationComment =>
+      study.deviationComments.getOrNull(currentPath.size);
+
+  GamebookState get gamebookState {
+    if (isAtEndOfChapter) return GamebookState.lessonComplete;
+
+    final bool myTurn = currentNode.position!.turn == pov;
+    if (isAtStartOfChapter && !myTurn) return GamebookState.startLesson;
+
+    return myTurn
+        ? GamebookState.findTheMove
+        : isOnMainline
+            ? GamebookState.correctMove
+            : GamebookState.incorrectMove;
+  }
+
   bool get isIntroductoryChapter =>
       currentNode.isRoot && currentNode.children.isEmpty;
 
@@ -576,7 +652,9 @@ class StudyState with _$StudyState {
             .flattened,
       );
 
-  PlayerSide get playerSide => PlayerSide.both;
+  PlayerSide get playerSide => gamebookActive
+      ? (pov == Side.white ? PlayerSide.white : PlayerSide.black)
+      : PlayerSide.both;
 }
 
 @freezed
diff --git a/lib/src/model/tv/tv_controller.dart b/lib/src/model/tv/tv_controller.dart
index f53e002667..78b6eeacc4 100644
--- a/lib/src/model/tv/tv_controller.dart
+++ b/lib/src/model/tv/tv_controller.dart
@@ -212,6 +212,8 @@ class TvController extends _$TvController {
           newState = newState.copyWith.game.clock!(
             white: data.clock!.white,
             black: data.clock!.black,
+            lag: data.clock!.lag,
+            at: data.clock!.at,
           );
         }
         if (!curState.isReplaying) {
@@ -228,6 +230,25 @@ class TvController extends _$TvController {
 
         state = AsyncData(newState);
 
+      case 'endData':
+        final endData =
+            GameEndEvent.fromJson(event.data as Map<String, dynamic>);
+        TvState newState = state.requireValue.copyWith(
+          game: state.requireValue.game.copyWith(
+            status: endData.status,
+            winner: endData.winner,
+          ),
+        );
+        if (endData.clock != null) {
+          newState = newState.copyWith.game.clock!(
+            white: endData.clock!.white,
+            black: endData.clock!.black,
+            at: DateTime.now(),
+            lag: null,
+          );
+        }
+        state = AsyncData(newState);
+
       case 'tvSelect':
         final json = event.data as Map<String, dynamic>;
         final eventChannel = pick(json, 'channel').asTvChannelOrNull();
diff --git a/lib/src/network/socket.dart b/lib/src/network/socket.dart
index 1997cf23e2..fdb9d7eed0 100644
--- a/lib/src/network/socket.dart
+++ b/lib/src/network/socket.dart
@@ -563,9 +563,10 @@ class SocketPool {
     return client;
   }
 
-  /// Disposes the pool and all its clients.
+  /// Disposes the pool and all its clients and resources.
   void dispose() {
     _averageLag.dispose();
+    _disposeTimers.forEach((_, t) => t?.cancel());
     _pool.forEach((_, c) => c._dispose());
   }
 }
diff --git a/lib/src/view/clock/clock_settings.dart b/lib/src/view/clock/clock_settings.dart
index a5371b4f91..decea94991 100644
--- a/lib/src/view/clock/clock_settings.dart
+++ b/lib/src/view/clock/clock_settings.dart
@@ -1,6 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:lichess_mobile/src/model/clock/clock_controller.dart';
+import 'package:lichess_mobile/src/model/clock/clock_tool_controller.dart';
 import 'package:lichess_mobile/src/model/common/time_increment.dart';
 import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
@@ -16,7 +16,7 @@ class ClockSettings extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final state = ref.watch(clockControllerProvider);
+    final state = ref.watch(clockToolControllerProvider);
     final buttonsEnabled = !state.started || state.paused;
 
     final isSoundEnabled = ref.watch(
@@ -36,7 +36,7 @@ class ClockSettings extends ConsumerWidget {
             iconSize: _iconSize,
             onPressed: buttonsEnabled
                 ? () {
-                    ref.read(clockControllerProvider.notifier).reset();
+                    ref.read(clockToolControllerProvider.notifier).reset();
                   }
                 : null,
             icon: const Icon(Icons.refresh),
@@ -57,18 +57,18 @@ class ClockSettings extends ConsumerWidget {
                       ),
                       builder: (BuildContext context) {
                         final options = ref.watch(
-                          clockControllerProvider
+                          clockToolControllerProvider
                               .select((value) => value.options),
                         );
                         return TimeControlModal(
                           excludeUltraBullet: true,
                           value: TimeIncrement(
-                            options.timePlayerTop.inSeconds,
-                            options.incrementPlayerTop.inSeconds,
+                            options.whiteTime.inSeconds,
+                            options.whiteIncrement.inSeconds,
                           ),
                           onSelected: (choice) {
                             ref
-                                .read(clockControllerProvider.notifier)
+                                .read(clockToolControllerProvider.notifier)
                                 .updateOptions(choice);
                           },
                         );
@@ -107,8 +107,8 @@ class _PlayResumeButton extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final controller = ref.read(clockControllerProvider.notifier);
-    final state = ref.watch(clockControllerProvider);
+    final controller = ref.read(clockToolControllerProvider.notifier);
+    final state = ref.watch(clockToolControllerProvider);
 
     if (!state.started) {
       return IconButton(
diff --git a/lib/src/view/clock/clock_screen.dart b/lib/src/view/clock/clock_tool_screen.dart
similarity index 73%
rename from lib/src/view/clock/clock_screen.dart
rename to lib/src/view/clock/clock_tool_screen.dart
index f01b4d4880..b9962a0802 100644
--- a/lib/src/view/clock/clock_screen.dart
+++ b/lib/src/view/clock/clock_tool_screen.dart
@@ -1,6 +1,7 @@
+import 'package:dartchess/dartchess.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:lichess_mobile/src/model/clock/clock_controller.dart';
+import 'package:lichess_mobile/src/model/clock/clock_tool_controller.dart';
 import 'package:lichess_mobile/src/model/common/time_increment.dart';
 import 'package:lichess_mobile/src/styles/styles.dart';
 import 'package:lichess_mobile/src/utils/immersive_mode.dart';
@@ -8,12 +9,12 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart';
 import 'package:lichess_mobile/src/view/clock/clock_settings.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 
 import 'custom_clock_settings.dart';
 
-class ClockScreen extends StatelessWidget {
-  const ClockScreen({super.key});
+class ClockToolScreen extends StatelessWidget {
+  const ClockToolScreen({super.key});
 
   @override
   Widget build(BuildContext context) {
@@ -23,12 +24,14 @@ class ClockScreen extends StatelessWidget {
   }
 }
 
+enum TilePosition { bottom, top }
+
 class _Body extends ConsumerWidget {
   const _Body();
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final state = ref.watch(clockControllerProvider);
+    final state = ref.watch(clockToolControllerProvider);
 
     return OrientationBuilder(
       builder: (context, orientation) {
@@ -37,16 +40,18 @@ class _Body extends ConsumerWidget {
           children: [
             Expanded(
               child: ClockTile(
+                position: TilePosition.top,
                 orientation: orientation,
-                playerType: ClockPlayerType.top,
+                playerType: state.bottomPlayer.opposite,
                 clockState: state,
               ),
             ),
             ClockSettings(orientation: orientation),
             Expanded(
               child: ClockTile(
+                position: TilePosition.bottom,
                 orientation: orientation,
-                playerType: ClockPlayerType.bottom,
+                playerType: state.bottomPlayer,
                 clockState: state,
               ),
             ),
@@ -59,20 +64,22 @@ class _Body extends ConsumerWidget {
 
 class ClockTile extends ConsumerWidget {
   const ClockTile({
+    required this.position,
     required this.playerType,
     required this.clockState,
     required this.orientation,
     super.key,
   });
 
-  final ClockPlayerType playerType;
+  final TilePosition position;
+  final Side playerType;
   final ClockState clockState;
   final Orientation orientation;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final colorScheme = Theme.of(context).colorScheme;
-    final backgroundColor = clockState.isLoser(playerType)
+    final backgroundColor = clockState.isFlagged(playerType)
         ? context.lichessColors.error
         : !clockState.paused && clockState.isPlayersTurn(playerType)
             ? colorScheme.primary
@@ -92,10 +99,10 @@ class ClockTile extends ConsumerWidget {
     );
 
     return RotatedBox(
-      quarterTurns: orientation == Orientation.portrait &&
-              playerType == ClockPlayerType.top
-          ? 2
-          : 0,
+      quarterTurns:
+          orientation == Orientation.portrait && position == TilePosition.top
+              ? 2
+              : 0,
       child: Stack(
         alignment: Alignment.center,
         fit: StackFit.expand,
@@ -107,15 +114,19 @@ class ClockTile extends ConsumerWidget {
               onTap: !clockState.started
                   ? () {
                       ref
-                          .read(clockControllerProvider.notifier)
-                          .setActiveSide(playerType);
+                          .read(clockToolControllerProvider.notifier)
+                          .setBottomPlayer(
+                            position == TilePosition.bottom
+                                ? Side.white
+                                : Side.black,
+                          );
                     }
                   : null,
               onTapDown: clockState.started &&
                       clockState.isPlayersMoveAllowed(playerType)
                   ? (_) {
                       ref
-                          .read(clockControllerProvider.notifier)
+                          .read(clockToolControllerProvider.notifier)
                           .onTap(playerType);
                     }
                   : null,
@@ -129,25 +140,19 @@ class ClockTile extends ConsumerWidget {
                     FittedBox(
                       child: AnimatedCrossFade(
                         duration: const Duration(milliseconds: 300),
-                        firstChild: CountdownClock(
-                          key: Key('${clockState.id}-$playerType'),
-                          padLeft: true,
-                          clockStyle: clockStyle,
-                          duration: clockState.getDuration(playerType),
-                          active: clockState.isActivePlayer(playerType),
-                          onFlag: () {
-                            ref
-                                .read(clockControllerProvider.notifier)
-                                .setLoser(playerType);
-                          },
-                          onStop: (remaining) {
-                            ref
-                                .read(clockControllerProvider.notifier)
-                                .updateDuration(playerType, remaining);
+                        firstChild: ValueListenableBuilder(
+                          valueListenable: clockState.getDuration(playerType),
+                          builder: (context, value, _) {
+                            return Clock(
+                              padLeft: true,
+                              clockStyle: clockStyle,
+                              timeLeft: value,
+                              active: clockState.isActivePlayer(playerType),
+                            );
                           },
                         ),
                         secondChild: const Icon(Icons.flag),
-                        crossFadeState: clockState.isLoser(playerType)
+                        crossFadeState: clockState.isFlagged(playerType)
                             ? CrossFadeState.showSecond
                             : CrossFadeState.showFirst,
                       ),
@@ -188,22 +193,22 @@ class ClockTile extends ConsumerWidget {
                           builder: (BuildContext context) =>
                               CustomClockSettings(
                             player: playerType,
-                            clock: playerType == ClockPlayerType.top
+                            clock: playerType == Side.white
                                 ? TimeIncrement.fromDurations(
-                                    clockState.options.timePlayerTop,
-                                    clockState.options.incrementPlayerTop,
+                                    clockState.options.whiteTime,
+                                    clockState.options.whiteIncrement,
                                   )
                                 : TimeIncrement.fromDurations(
-                                    clockState.options.timePlayerBottom,
-                                    clockState.options.incrementPlayerBottom,
+                                    clockState.options.blackTime,
+                                    clockState.options.blackIncrement,
                                   ),
                             onSubmit: (
-                              ClockPlayerType player,
+                              Side player,
                               TimeIncrement clock,
                             ) {
                               Navigator.of(context).pop();
                               ref
-                                  .read(clockControllerProvider.notifier)
+                                  .read(clockToolControllerProvider.notifier)
                                   .updateOptionsCustom(clock, player);
                             },
                           ),
diff --git a/lib/src/view/clock/custom_clock_settings.dart b/lib/src/view/clock/custom_clock_settings.dart
index ee2bdc6d2d..3ac2172a1a 100644
--- a/lib/src/view/clock/custom_clock_settings.dart
+++ b/lib/src/view/clock/custom_clock_settings.dart
@@ -1,5 +1,5 @@
+import 'package:dartchess/dartchess.dart';
 import 'package:flutter/material.dart';
-import 'package:lichess_mobile/src/model/clock/clock_controller.dart';
 import 'package:lichess_mobile/src/model/common/time_increment.dart';
 import 'package:lichess_mobile/src/model/lobby/game_setup_preferences.dart';
 import 'package:lichess_mobile/src/styles/styles.dart';
@@ -16,9 +16,9 @@ class CustomClockSettings extends StatefulWidget {
     required this.clock,
   });
 
-  final ClockPlayerType player;
+  final Side player;
   final TimeIncrement clock;
-  final void Function(ClockPlayerType player, TimeIncrement clock) onSubmit;
+  final void Function(Side player, TimeIncrement clock) onSubmit;
 
   @override
   State<CustomClockSettings> createState() => _CustomClockSettingsState();
diff --git a/lib/src/view/correspondence/offline_correspondence_game_screen.dart b/lib/src/view/correspondence/offline_correspondence_game_screen.dart
index 8f7c6b6b08..d89d631db7 100644
--- a/lib/src/view/correspondence/offline_correspondence_game_screen.dart
+++ b/lib/src/view/correspondence/offline_correspondence_game_screen.dart
@@ -282,7 +282,7 @@ class _BodyState extends ConsumerState<_Body> {
                 data: (games) {
                   final nextTurn = games
                       .whereNot((g) => g.$2.id == game.id)
-                      .firstWhereOrNull((g) => g.$2.isPlayerTurn);
+                      .firstWhereOrNull((g) => g.$2.isMyTurn);
                   return nextTurn != null
                       ? () {
                           widget.onGameChanged(nextTurn);
diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart
index a4666b2863..ab7c41cec3 100644
--- a/lib/src/view/game/archived_game_screen.dart
+++ b/lib/src/view/game/archived_game_screen.dart
@@ -21,7 +21,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 import 'package:lichess_mobile/src/widgets/platform_scaffold.dart';
 
 import 'archived_game_screen_providers.dart';
@@ -258,23 +258,13 @@ class _BoardBody extends ConsumerWidget {
         final black = GamePlayer(
           key: const ValueKey('black-player'),
           player: gameData.black,
-          clock: blackClock != null
-              ? CountdownClock(
-                  duration: blackClock,
-                  active: false,
-                )
-              : null,
+          clock: blackClock != null ? Clock(timeLeft: blackClock) : null,
           materialDiff: game.materialDiffAt(cursor, Side.black),
         );
         final white = GamePlayer(
           key: const ValueKey('white-player'),
           player: gameData.white,
-          clock: whiteClock != null
-              ? CountdownClock(
-                  duration: whiteClock,
-                  active: false,
-                )
-              : null,
+          clock: whiteClock != null ? Clock(timeLeft: whiteClock) : null,
           materialDiff: game.materialDiffAt(cursor, Side.white),
         );
 
diff --git a/lib/src/view/game/correspondence_clock_widget.dart b/lib/src/view/game/correspondence_clock_widget.dart
index 418162624f..73f4e2a076 100644
--- a/lib/src/view/game/correspondence_clock_widget.dart
+++ b/lib/src/view/game/correspondence_clock_widget.dart
@@ -6,7 +6,7 @@ import 'package:lichess_mobile/src/constants.dart';
 import 'package:lichess_mobile/src/model/settings/brightness.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
 import 'package:lichess_mobile/src/utils/screen.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 
 class CorrespondenceClock extends ConsumerStatefulWidget {
   /// The duration left on the clock.
diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart
index eea37bfced..0dcb9e9d29 100644
--- a/lib/src/view/game/game_body.dart
+++ b/lib/src/view/game/game_body.dart
@@ -6,7 +6,6 @@ import 'package:dartchess/dartchess.dart';
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:lichess_mobile/src/model/account/account_preferences.dart';
 import 'package:lichess_mobile/src/model/account/account_repository.dart';
 import 'package:lichess_mobile/src/model/common/id.dart';
 import 'package:lichess_mobile/src/model/common/speed.dart';
@@ -27,7 +26,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart';
 import 'package:lichess_mobile/src/widgets/user_full_name.dart';
 import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart';
@@ -104,11 +103,6 @@ class GameBody extends ConsumerWidget {
     );
 
     final boardPreferences = ref.watch(boardPreferencesProvider);
-    final emergencySoundEnabled = ref.watch(clockSoundProvider).maybeWhen(
-          data: (clockSound) => clockSound,
-          orElse: () => true,
-        );
-
     final blindfoldMode = ref.watch(
       gamePreferencesProvider.select(
         (prefs) => prefs.blindfoldMode,
@@ -137,6 +131,7 @@ class GameBody extends ConsumerWidget {
               : null,
           mePlaying: youAre == Side.black,
           zenMode: gameState.isZenModeActive,
+          clockPosition: boardPreferences.clockPosition,
           confirmMoveCallbacks:
               youAre == Side.black && gameState.moveToConfirm != null
                   ? (
@@ -148,24 +143,35 @@ class GameBody extends ConsumerWidget {
                       },
                     )
                   : null,
-          clock: gameState.game.meta.clock != null
-              ? CountdownClock(
-                  key: blackClockKey,
-                  duration: archivedBlackClock ?? gameState.game.clock!.black,
-                  active: gameState.activeClockSide == Side.black,
-                  emergencyThreshold: youAre == Side.black
-                      ? gameState.game.meta.clock?.emergency
-                      : null,
-                  emergencySoundEnabled: emergencySoundEnabled,
-                  onFlag: () => ref.read(ctrlProvider.notifier).onFlag(),
+          clock: archivedBlackClock != null
+              ? Clock(
+                  timeLeft: archivedBlackClock,
+                  active: false,
                 )
-              : gameState.game.correspondenceClock != null
-                  ? CorrespondenceClock(
-                      duration: gameState.game.correspondenceClock!.black,
-                      active: gameState.activeClockSide == Side.black,
-                      onFlag: () => ref.read(ctrlProvider.notifier).onFlag(),
+              : gameState.liveClock != null
+                  ? RepaintBoundary(
+                      child: ValueListenableBuilder(
+                        key: blackClockKey,
+                        valueListenable: gameState.liveClock!.black,
+                        builder: (context, value, _) {
+                          return Clock(
+                            timeLeft: value,
+                            active: gameState.activeClockSide == Side.black,
+                            emergencyThreshold: youAre == Side.black
+                                ? gameState.game.meta.clock?.emergency
+                                : null,
+                          );
+                        },
+                      ),
                     )
-                  : null,
+                  : gameState.game.correspondenceClock != null
+                      ? CorrespondenceClock(
+                          duration: gameState.game.correspondenceClock!.black,
+                          active: gameState.activeClockSide == Side.black,
+                          onFlag: () =>
+                              ref.read(ctrlProvider.notifier).onFlag(),
+                        )
+                      : null,
         );
         final white = GamePlayer(
           player: gameState.game.white,
@@ -178,6 +184,7 @@ class GameBody extends ConsumerWidget {
               : null,
           mePlaying: youAre == Side.white,
           zenMode: gameState.isZenModeActive,
+          clockPosition: boardPreferences.clockPosition,
           confirmMoveCallbacks:
               youAre == Side.white && gameState.moveToConfirm != null
                   ? (
@@ -189,24 +196,35 @@ class GameBody extends ConsumerWidget {
                       },
                     )
                   : null,
-          clock: gameState.game.meta.clock != null
-              ? CountdownClock(
-                  key: whiteClockKey,
-                  duration: archivedWhiteClock ?? gameState.game.clock!.white,
-                  active: gameState.activeClockSide == Side.white,
-                  emergencyThreshold: youAre == Side.white
-                      ? gameState.game.meta.clock?.emergency
-                      : null,
-                  emergencySoundEnabled: emergencySoundEnabled,
-                  onFlag: () => ref.read(ctrlProvider.notifier).onFlag(),
+          clock: archivedWhiteClock != null
+              ? Clock(
+                  timeLeft: archivedWhiteClock,
+                  active: false,
                 )
-              : gameState.game.correspondenceClock != null
-                  ? CorrespondenceClock(
-                      duration: gameState.game.correspondenceClock!.white,
-                      active: gameState.activeClockSide == Side.white,
-                      onFlag: () => ref.read(ctrlProvider.notifier).onFlag(),
+              : gameState.liveClock != null
+                  ? RepaintBoundary(
+                      child: ValueListenableBuilder(
+                        key: whiteClockKey,
+                        valueListenable: gameState.liveClock!.white,
+                        builder: (context, value, _) {
+                          return Clock(
+                            timeLeft: value,
+                            active: gameState.activeClockSide == Side.white,
+                            emergencyThreshold: youAre == Side.white
+                                ? gameState.game.meta.clock?.emergency
+                                : null,
+                          );
+                        },
+                      ),
                     )
-                  : null,
+                  : gameState.game.correspondenceClock != null
+                      ? CorrespondenceClock(
+                          duration: gameState.game.correspondenceClock!.white,
+                          active: gameState.activeClockSide == Side.white,
+                          onFlag: () =>
+                              ref.read(ctrlProvider.notifier).onFlag(),
+                        )
+                      : null,
         );
 
         final isBoardTurned = ref.watch(isBoardTurnedProvider);
diff --git a/lib/src/view/game/game_common_widgets.dart b/lib/src/view/game/game_common_widgets.dart
index 8d3718cf57..94199f99cf 100644
--- a/lib/src/view/game/game_common_widgets.dart
+++ b/lib/src/view/game/game_common_widgets.dart
@@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/model/lobby/game_seek.dart';
 import 'package:lichess_mobile/src/network/http.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
 import 'package:lichess_mobile/src/utils/share.dart';
+import 'package:lichess_mobile/src/view/settings/toggle_sound_button.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
@@ -64,6 +65,7 @@ class GameAppBar extends ConsumerWidget {
                   ? _ChallengeGameTitle(challenge: challenge!)
                   : const SizedBox.shrink(),
       actions: [
+        const ToggleSoundButton(),
         if (id != null)
           AppBarIconButton(
             onPressed: () => showAdaptiveBottomSheet<void>(
@@ -71,6 +73,9 @@ class GameAppBar extends ConsumerWidget {
               isDismissible: true,
               isScrollControlled: true,
               showDragHandle: true,
+              constraints: BoxConstraints(
+                minHeight: MediaQuery.sizeOf(context).height * 0.5,
+              ),
               builder: (_) => GameSettings(id: id!),
             ),
             semanticsLabel: context.l10n.settingsSettings,
diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart
index 394ebcd672..9691cfae76 100644
--- a/lib/src/view/game/game_player.dart
+++ b/lib/src/view/game/game_player.dart
@@ -5,7 +5,9 @@ import 'package:dartchess/dartchess.dart';
 import 'package:fast_immutable_collections/fast_immutable_collections.dart';
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:lichess_mobile/src/constants.dart';
+import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
 import 'package:lichess_mobile/src/model/game/material_diff.dart';
 import 'package:lichess_mobile/src/model/game/player.dart';
 import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
@@ -33,6 +35,7 @@ class GamePlayer extends StatelessWidget {
     this.shouldLinkToUserProfile = true,
     this.mePlaying = false,
     this.zenMode = false,
+    this.clockPosition = ClockPosition.right,
     super.key,
   });
 
@@ -47,6 +50,7 @@ class GamePlayer extends StatelessWidget {
   final bool shouldLinkToUserProfile;
   final bool mePlaying;
   final bool zenMode;
+  final ClockPosition clockPosition;
 
   /// Time left for the player to move at the start of the game.
   final Duration? timeToMove;
@@ -63,7 +67,9 @@ class GamePlayer extends StatelessWidget {
       children: [
         if (!zenMode)
           Row(
-            mainAxisAlignment: MainAxisAlignment.start,
+            mainAxisAlignment: clockPosition == ClockPosition.right
+                ? MainAxisAlignment.start
+                : MainAxisAlignment.end,
             children: [
               if (player.user != null) ...[
                 Icon(
@@ -147,9 +153,31 @@ class GamePlayer extends StatelessWidget {
         else if (materialDiff != null)
           MaterialDifferenceDisplay(
             materialDiff: materialDiff!,
-            materialDifferenceFormat: materialDifferenceFormat!,
-          )
-        else
+            materialDifferenceFormat: materialDifferenceFormat!,),
+          Row(
+            mainAxisAlignment: clockPosition == ClockPosition.right
+                ? MainAxisAlignment.start
+                : MainAxisAlignment.end,
+            children: [
+              for (final role in Role.values)
+                for (int i = 0; i < materialDiff!.pieces[role]!; i++)
+                  Icon(
+                    _iconByRole[role],
+                    size: 13,
+                    color: Colors.grey,
+                  ),
+              const SizedBox(width: 3),
+              Text(
+                style: const TextStyle(
+                  fontSize: 13,
+                  color: Colors.grey,
+                ),
+                materialDiff != null && materialDiff!.score > 0
+                    ? '+${materialDiff!.score}'
+                    : '',
+              ),
+            ],
+          ),
           // to avoid shifts use an empty text widget
           const Text('', style: TextStyle(fontSize: 13)),
       ],
@@ -159,6 +187,8 @@ class GamePlayer extends StatelessWidget {
       mainAxisAlignment: MainAxisAlignment.spaceBetween,
       crossAxisAlignment: CrossAxisAlignment.center,
       children: [
+        if (clock != null && clockPosition == ClockPosition.left)
+          Flexible(flex: 3, child: clock!),
         if (mePlaying && confirmMoveCallbacks != null)
           Expanded(
             flex: 7,
@@ -194,7 +224,8 @@ class GamePlayer extends StatelessWidget {
                   : playerWidget,
             ),
           ),
-        if (clock != null) Flexible(flex: 3, child: clock!),
+        if (clock != null && clockPosition == ClockPosition.right)
+          Flexible(flex: 3, child: clock!),
       ],
     );
   }
@@ -244,7 +275,7 @@ class ConfirmMove extends StatelessWidget {
   }
 }
 
-class MoveExpiration extends StatefulWidget {
+class MoveExpiration extends ConsumerStatefulWidget {
   const MoveExpiration({
     required this.timeToMove,
     required this.mePlaying,
@@ -255,13 +286,14 @@ class MoveExpiration extends StatefulWidget {
   final bool mePlaying;
 
   @override
-  State<MoveExpiration> createState() => _MoveExpirationState();
+  ConsumerState<MoveExpiration> createState() => _MoveExpirationState();
 }
 
-class _MoveExpirationState extends State<MoveExpiration> {
+class _MoveExpirationState extends ConsumerState<MoveExpiration> {
   static const _period = Duration(milliseconds: 1000);
   Timer? _timer;
   Duration timeLeft = Duration.zero;
+  bool playedEmergencySound = false;
 
   Timer startTimer() {
     return Timer.periodic(_period, (timer) {
@@ -299,6 +331,14 @@ class _MoveExpirationState extends State<MoveExpiration> {
   Widget build(BuildContext context) {
     final secs = timeLeft.inSeconds.remainder(60);
     final emerg = timeLeft <= const Duration(seconds: 8);
+
+    if (emerg && widget.mePlaying && !playedEmergencySound) {
+      ref.read(soundServiceProvider).play(Sound.lowTime);
+      setState(() {
+        playedEmergencySound = true;
+      });
+    }
+
     return secs <= 20
         ? Text(
             context.l10n.nbSecondsToPlayTheFirstMove(secs),
diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart
index 25e9c2b1f7..42d6bf0290 100644
--- a/lib/src/view/game/game_settings.dart
+++ b/lib/src/view/game/game_settings.dart
@@ -1,5 +1,4 @@
 import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:lichess_mobile/l10n/l10n.dart';
 import 'package:lichess_mobile/src/model/account/account_preferences.dart';
@@ -7,9 +6,11 @@ import 'package:lichess_mobile/src/model/common/id.dart';
 import 'package:lichess_mobile/src/model/game/game_controller.dart';
 import 'package:lichess_mobile/src/model/game/game_preferences.dart';
 import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
-import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
+import 'package:lichess_mobile/src/utils/navigation.dart';
+import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
+import 'package:lichess_mobile/src/widgets/list.dart';
 import 'package:lichess_mobile/src/widgets/settings.dart';
 
 import '../../widgets/adaptive_choice_picker.dart';
@@ -22,31 +23,12 @@ class GameSettings extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final isSoundEnabled = ref.watch(
-      generalPreferencesProvider.select(
-        (prefs) => prefs.isSoundEnabled,
-      ),
-    );
-    final boardPrefs = ref.watch(boardPreferencesProvider);
     final gamePrefs = ref.watch(gamePreferencesProvider);
     final userPrefsAsync = ref.watch(userGamePrefsProvider(id));
+    final boardPrefs = ref.watch(boardPreferencesProvider);
 
     return BottomSheetScrollableContainer(
       children: [
-        SwitchSettingTile(
-          title: Text(context.l10n.sound),
-          value: isSoundEnabled,
-          onChanged: (value) {
-            ref.read(generalPreferencesProvider.notifier).toggleSoundEnabled();
-          },
-        ),
-        SwitchSettingTile(
-          title: Text(context.l10n.mobileSettingsHapticFeedback),
-          value: boardPrefs.hapticFeedback,
-          onChanged: (value) {
-            ref.read(boardPreferencesProvider.notifier).toggleHapticFeedback();
-          },
-        ),
         ...userPrefsAsync.maybeWhen(
           data: (data) {
             return [
@@ -111,6 +93,18 @@ class GameSettings extends ConsumerWidget {
             ref.read(boardPreferencesProvider.notifier).togglePieceAnimation();
           },
         ),
+        PlatformListTile(
+          // TODO translate
+          title: const Text('Board settings'),
+          trailing: const Icon(CupertinoIcons.chevron_right),
+          onTap: () {
+            pushPlatformRoute(
+              context,
+              fullscreenDialog: true,
+              screen: const BoardSettingsScreen(),
+            );
+          },
+        ),
         SwitchSettingTile(
           title: Text(
             context.l10n.toggleTheChat,
diff --git a/lib/src/view/game/message_screen.dart b/lib/src/view/game/message_screen.dart
index d684f855c0..00187cc040 100644
--- a/lib/src/view/game/message_screen.dart
+++ b/lib/src/view/game/message_screen.dart
@@ -83,29 +83,34 @@ class _Body extends ConsumerWidget {
           child: GestureDetector(
             onTap: () => FocusScope.of(context).unfocus(),
             child: chatStateAsync.when(
-              data: (chatState) => ListView.builder(
-                // remove the automatic bottom padding of the ListView, which on iOS
-                // corresponds to the safe area insets
-                // and which is here taken care of by the _ChatBottomBar
-                padding: MediaQuery.of(context).padding.copyWith(bottom: 0),
-                reverse: true,
-                itemCount: chatState.messages.length,
-                itemBuilder: (context, index) {
-                  final message =
-                      chatState.messages[chatState.messages.length - index - 1];
-                  return (message.username == 'lichess')
-                      ? _MessageAction(message: message.message)
-                      : (message.username == me?.name)
-                          ? _MessageBubble(
-                              you: true,
-                              message: message.message,
-                            )
-                          : _MessageBubble(
-                              you: false,
-                              message: message.message,
-                            );
-                },
-              ),
+              data: (chatState) {
+                final selectedMessages = chatState.messages
+                    .where((m) => !m.troll && !m.deleted && !isSpam(m))
+                    .toList();
+                final messagesCount = selectedMessages.length;
+                return ListView.builder(
+                  // remove the automatic bottom padding of the ListView, which on iOS
+                  // corresponds to the safe area insets
+                  // and which is here taken care of by the _ChatBottomBar
+                  padding: MediaQuery.of(context).padding.copyWith(bottom: 0),
+                  reverse: true,
+                  itemCount: messagesCount,
+                  itemBuilder: (context, index) {
+                    final message = selectedMessages[messagesCount - index - 1];
+                    return (message.username == 'lichess')
+                        ? _MessageAction(message: message.message)
+                        : (message.username == me?.name)
+                            ? _MessageBubble(
+                                you: true,
+                                message: message.message,
+                              )
+                            : _MessageBubble(
+                                you: false,
+                                message: message.message,
+                              );
+                  },
+                );
+              },
               loading: () => const Center(
                 child: CircularProgressIndicator(),
               ),
diff --git a/lib/src/view/over_the_board/over_the_board_screen.dart b/lib/src/view/over_the_board/over_the_board_screen.dart
index 5478945df8..7470cac66d 100644
--- a/lib/src/view/over_the_board/over_the_board_screen.dart
+++ b/lib/src/view/over_the_board/over_the_board_screen.dart
@@ -25,7 +25,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 import 'package:lichess_mobile/src/widgets/platform_scaffold.dart';
 
 class OverTheBoardScreen extends StatelessWidget {
diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart
index 6f51d20735..ef8681c596 100644
--- a/lib/src/view/puzzle/puzzle_screen.dart
+++ b/lib/src/view/puzzle/puzzle_screen.dart
@@ -31,6 +31,7 @@ import 'package:lichess_mobile/src/view/account/rating_pref_aware.dart';
 import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
 import 'package:lichess_mobile/src/view/game/archived_game_screen.dart';
 import 'package:lichess_mobile/src/view/puzzle/puzzle_settings_screen.dart';
+import 'package:lichess_mobile/src/view/settings/toggle_sound_button.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart';
@@ -91,6 +92,7 @@ class _PuzzleScreenState extends ConsumerState<PuzzleScreen> with RouteAware {
       child: PlatformScaffold(
         appBar: PlatformAppBar(
           actions: const [
+            ToggleSoundButton(),
             _PuzzleSettingsButton(),
           ],
           title: _Title(angle: widget.angle),
@@ -604,6 +606,9 @@ class _PuzzleSettingsButton extends StatelessWidget {
         isDismissible: true,
         isScrollControlled: true,
         showDragHandle: true,
+        constraints: BoxConstraints(
+          minHeight: MediaQuery.sizeOf(context).height * 0.5,
+        ),
         builder: (_) => const PuzzleSettingsScreen(),
       ),
       semanticsLabel: context.l10n.settingsSettings,
diff --git a/lib/src/view/puzzle/puzzle_settings_screen.dart b/lib/src/view/puzzle/puzzle_settings_screen.dart
index 7f59b61d24..33e14c9518 100644
--- a/lib/src/view/puzzle/puzzle_settings_screen.dart
+++ b/lib/src/view/puzzle/puzzle_settings_screen.dart
@@ -1,11 +1,11 @@
 import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:lichess_mobile/src/model/puzzle/puzzle_preferences.dart';
-import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
-import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
+import 'package:lichess_mobile/src/utils/navigation.dart';
+import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
+import 'package:lichess_mobile/src/widgets/list.dart';
 import 'package:lichess_mobile/src/widgets/settings.dart';
 
 class PuzzleSettingsScreen extends ConsumerWidget {
@@ -13,23 +13,11 @@ class PuzzleSettingsScreen extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final isSoundEnabled = ref.watch(
-      generalPreferencesProvider.select((pref) => pref.isSoundEnabled),
-    );
     final autoNext = ref.watch(
       puzzlePreferencesProvider.select((value) => value.autoNext),
     );
-    final boardPrefs = ref.watch(boardPreferencesProvider);
-
     return BottomSheetScrollableContainer(
       children: [
-        SwitchSettingTile(
-          title: Text(context.l10n.sound),
-          value: isSoundEnabled,
-          onChanged: (value) {
-            ref.read(generalPreferencesProvider.notifier).toggleSoundEnabled();
-          },
-        ),
         SwitchSettingTile(
           title: Text(context.l10n.puzzleJumpToNextPuzzleImmediately),
           value: autoNext,
@@ -37,28 +25,15 @@ class PuzzleSettingsScreen extends ConsumerWidget {
             ref.read(puzzlePreferencesProvider.notifier).setAutoNext(value);
           },
         ),
-        SwitchSettingTile(
-          // TODO: Add l10n
-          title: const Text('Shape drawing'),
-          subtitle: const Text(
-            'Draw shapes using two fingers.',
-            maxLines: 5,
-            textAlign: TextAlign.justify,
-          ),
-          value: boardPrefs.enableShapeDrawings,
-          onChanged: (value) {
-            ref
-                .read(boardPreferencesProvider.notifier)
-                .toggleEnableShapeDrawings();
-          },
-        ),
-        SwitchSettingTile(
-          title: Text(
-            context.l10n.preferencesPieceAnimation,
-          ),
-          value: boardPrefs.pieceAnimation,
-          onChanged: (value) {
-            ref.read(boardPreferencesProvider.notifier).togglePieceAnimation();
+        PlatformListTile(
+          title: const Text('Board settings'),
+          trailing: const Icon(CupertinoIcons.chevron_right),
+          onTap: () {
+            pushPlatformRoute(
+              context,
+              fullscreenDialog: true,
+              screen: const BoardSettingsScreen(),
+            );
           },
         ),
       ],
diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart
index e2bc8b5ccd..d126acc24b 100644
--- a/lib/src/view/settings/board_settings_screen.dart
+++ b/lib/src/view/settings/board_settings_screen.dart
@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:lichess_mobile/l10n/l10n.dart';
 import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
+import 'package:lichess_mobile/src/styles/styles.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
 import 'package:lichess_mobile/src/utils/navigation.dart';
 import 'package:lichess_mobile/src/utils/screen.dart';
 import 'package:lichess_mobile/src/utils/system.dart';
-import 'package:lichess_mobile/src/view/settings/piece_shift_method_settings_screen.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart';
 import 'package:lichess_mobile/src/widgets/list.dart';
 import 'package:lichess_mobile/src/widgets/platform.dart';
@@ -53,6 +53,7 @@ class _Body extends ConsumerWidget {
       child: ListView(
         children: [
           ListSection(
+            header: SettingsSectionTitle(context.l10n.preferencesGameBehavior),
             hasLeading: false,
             showDivider: false,
             children: [
@@ -88,29 +89,96 @@ class _Body extends ConsumerWidget {
                 },
               ),
               SwitchSettingTile(
-                // TODO: Add l10n
-                title: const Text('Shape drawing'),
+                title: Text(context.l10n.mobilePrefMagnifyDraggedPiece),
+                value: boardPrefs.magnifyDraggedPiece,
+                onChanged: (value) {
+                  ref
+                      .read(boardPreferencesProvider.notifier)
+                      .toggleMagnifyDraggedPiece();
+                },
+              ),
+              SettingsListTile(
+                // TODO translate
+                settingsLabel: const Text('Drag target'),
+                explanation:
+                    // TODO translate
+                    'How the target square is highlighted when dragging a piece.',
+                settingsValue: dragTargetKindLabel(boardPrefs.dragTargetKind),
+                onTap: () {
+                  if (Theme.of(context).platform == TargetPlatform.android) {
+                    showChoicePicker(
+                      context,
+                      choices: DragTargetKind.values,
+                      selectedItem: boardPrefs.dragTargetKind,
+                      labelBuilder: (t) => Text(dragTargetKindLabel(t)),
+                      onSelectedItemChanged: (DragTargetKind? value) {
+                        ref
+                            .read(boardPreferencesProvider.notifier)
+                            .setDragTargetKind(
+                              value ?? DragTargetKind.circle,
+                            );
+                      },
+                    );
+                  } else {
+                    pushPlatformRoute(
+                      context,
+                      title: 'Dragged piece target',
+                      builder: (context) =>
+                          const DragTargetKindSettingsScreen(),
+                    );
+                  }
+                },
+              ),
+              SwitchSettingTile(
+                // TODO translate
+                title: const Text('Touch feedback'),
+                value: boardPrefs.hapticFeedback,
                 subtitle: const Text(
-                  'Draw shapes using two fingers on game and puzzle boards.',
+                  // TODO translate
+                  'Vibrate when moving pieces or capturing them.',
                   maxLines: 5,
                   textAlign: TextAlign.justify,
                 ),
-                value: boardPrefs.enableShapeDrawings,
                 onChanged: (value) {
                   ref
                       .read(boardPreferencesProvider.notifier)
-                      .toggleEnableShapeDrawings();
+                      .toggleHapticFeedback();
                 },
               ),
               SwitchSettingTile(
-                title: Text(context.l10n.mobileSettingsHapticFeedback),
-                value: boardPrefs.hapticFeedback,
+                title: Text(
+                  context.l10n.preferencesPieceAnimation,
+                ),
+                value: boardPrefs.pieceAnimation,
                 onChanged: (value) {
                   ref
                       .read(boardPreferencesProvider.notifier)
-                      .toggleHapticFeedback();
+                      .togglePieceAnimation();
+                },
+              ),
+              SwitchSettingTile(
+                // TODO: Add l10n
+                title: const Text('Shape drawing'),
+                subtitle: const Text(
+                  // TODO: translate
+                  'Draw shapes using two fingers: maintain one finger on an empty square and drag another finger to draw a shape.',
+                  maxLines: 5,
+                  textAlign: TextAlign.justify,
+                ),
+                value: boardPrefs.enableShapeDrawings,
+                onChanged: (value) {
+                  ref
+                      .read(boardPreferencesProvider.notifier)
+                      .toggleEnableShapeDrawings();
                 },
               ),
+            ],
+          ),
+          ListSection(
+            header: SettingsSectionTitle(context.l10n.preferencesDisplay),
+            hasLeading: false,
+            showDivider: false,
+            children: [
               if (Theme.of(context).platform == TargetPlatform.android &&
                   !isTabletOrLarger(context))
                 androidVersionAsync.maybeWhen(
@@ -132,6 +200,30 @@ class _Body extends ConsumerWidget {
                       : const SizedBox.shrink(),
                   orElse: () => const SizedBox.shrink(),
                 ),
+              SettingsListTile(
+                //TODO Add l10n
+                settingsLabel: const Text('Clock position'),
+                settingsValue: boardPrefs.clockPosition.label,
+                onTap: () {
+                  if (Theme.of(context).platform == TargetPlatform.android) {
+                    showChoicePicker(
+                      context,
+                      choices: ClockPosition.values,
+                      selectedItem: boardPrefs.clockPosition,
+                      labelBuilder: (t) => Text(t.label),
+                      onSelectedItemChanged: (ClockPosition? value) => ref
+                          .read(boardPreferencesProvider.notifier)
+                          .setClockPosition(value ?? ClockPosition.right),
+                    );
+                  } else {
+                    pushPlatformRoute(
+                      context,
+                      title: 'Clock position',
+                      builder: (context) => const BoardClockPositionScreen(),
+                    );
+                  }
+                },
+              ),
               SwitchSettingTile(
                 title: Text(
                   context.l10n.preferencesPieceDestinations,
@@ -213,3 +305,122 @@ class _Body extends ConsumerWidget {
     );
   }
 }
+
+class PieceShiftMethodSettingsScreen extends ConsumerWidget {
+  const PieceShiftMethodSettingsScreen({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final pieceShiftMethod = ref.watch(
+      boardPreferencesProvider.select(
+        (state) => state.pieceShiftMethod,
+      ),
+    );
+
+    void onChanged(PieceShiftMethod? value) {
+      ref
+          .read(boardPreferencesProvider.notifier)
+          .setPieceShiftMethod(value ?? PieceShiftMethod.either);
+    }
+
+    return CupertinoPageScaffold(
+      navigationBar: const CupertinoNavigationBar(),
+      child: SafeArea(
+        child: ListView(
+          children: [
+            ChoicePicker(
+              notchedTile: true,
+              choices: PieceShiftMethod.values,
+              selectedItem: pieceShiftMethod,
+              titleBuilder: (t) => Text(pieceShiftMethodl10n(context, t)),
+              onSelectedItemChanged: onChanged,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class BoardClockPositionScreen extends ConsumerWidget {
+  const BoardClockPositionScreen({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final clockPosition = ref.watch(
+      boardPreferencesProvider.select((state) => state.clockPosition),
+    );
+    void onChanged(ClockPosition? value) => ref
+        .read(boardPreferencesProvider.notifier)
+        .setClockPosition(value ?? ClockPosition.right);
+    return CupertinoPageScaffold(
+      navigationBar: const CupertinoNavigationBar(),
+      child: SafeArea(
+        child: ListView(
+          children: [
+            ChoicePicker(
+              choices: ClockPosition.values,
+              selectedItem: clockPosition,
+              titleBuilder: (t) => Text(t.label),
+              onSelectedItemChanged: onChanged,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class DragTargetKindSettingsScreen extends ConsumerWidget {
+  const DragTargetKindSettingsScreen({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final dragTargetKind = ref.watch(
+      boardPreferencesProvider.select(
+        (state) => state.dragTargetKind,
+      ),
+    );
+
+    void onChanged(DragTargetKind? value) {
+      ref
+          .read(boardPreferencesProvider.notifier)
+          .setDragTargetKind(value ?? DragTargetKind.circle);
+    }
+
+    return CupertinoPageScaffold(
+      navigationBar: const CupertinoNavigationBar(),
+      child: SafeArea(
+        child: ListView(
+          children: [
+            Padding(
+              padding:
+                  Styles.horizontalBodyPadding.add(Styles.sectionTopPadding),
+              child: const Text(
+                'How the target square is highlighted when dragging a piece.',
+              ),
+            ),
+            ChoicePicker(
+              notchedTile: true,
+              choices: DragTargetKind.values,
+              selectedItem: dragTargetKind,
+              titleBuilder: (t) => Text(dragTargetKindLabel(t)),
+              onSelectedItemChanged: onChanged,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+String pieceShiftMethodl10n(
+  BuildContext context,
+  PieceShiftMethod pieceShiftMethod,
+) =>
+    switch (pieceShiftMethod) {
+      // TODO add this to mobile translations
+      PieceShiftMethod.either => 'Either tap or drag',
+      PieceShiftMethod.drag => context.l10n.preferencesDragPiece,
+      PieceShiftMethod.tapTwoSquares => 'Tap two squares',
+    };
diff --git a/lib/src/view/settings/piece_shift_method_settings_screen.dart b/lib/src/view/settings/piece_shift_method_settings_screen.dart
deleted file mode 100644
index 4edc23ba8d..0000000000
--- a/lib/src/view/settings/piece_shift_method_settings_screen.dart
+++ /dev/null
@@ -1,79 +0,0 @@
-import 'package:chessground/chessground.dart';
-import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
-import 'package:lichess_mobile/src/utils/l10n_context.dart';
-import 'package:lichess_mobile/src/widgets/platform.dart';
-import 'package:lichess_mobile/src/widgets/settings.dart';
-
-class PieceShiftMethodSettingsScreen extends StatelessWidget {
-  const PieceShiftMethodSettingsScreen({super.key});
-
-  @override
-  Widget build(BuildContext context) {
-    return PlatformWidget(
-      androidBuilder: _androidBuilder,
-      iosBuilder: _iosBuilder,
-    );
-  }
-
-  Widget _androidBuilder(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text(context.l10n.preferencesHowDoYouMovePieces)),
-      body: _Body(),
-    );
-  }
-
-  Widget _iosBuilder(BuildContext context) {
-    return CupertinoPageScaffold(
-      navigationBar: const CupertinoNavigationBar(),
-      child: _Body(),
-    );
-  }
-}
-
-String pieceShiftMethodl10n(
-  BuildContext context,
-  PieceShiftMethod pieceShiftMethod,
-) =>
-    switch (pieceShiftMethod) {
-      // This is called 'Either' in the Web UI, but in the app we might display this string
-      // without having the other values as context, so we need to be more explicit.
-      // TODO add this to mobile translations
-      PieceShiftMethod.either => 'Either click or drag',
-      PieceShiftMethod.drag => context.l10n.preferencesDragPiece,
-      // TODO This string uses 'click', we might want to use 'tap' instead in a mobile-specific translation
-      PieceShiftMethod.tapTwoSquares => context.l10n.preferencesClickTwoSquares,
-    };
-
-class _Body extends ConsumerWidget {
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-    final pieceShiftMethod = ref.watch(
-      boardPreferencesProvider.select(
-        (state) => state.pieceShiftMethod,
-      ),
-    );
-
-    void onChanged(PieceShiftMethod? value) {
-      ref
-          .read(boardPreferencesProvider.notifier)
-          .setPieceShiftMethod(value ?? PieceShiftMethod.either);
-    }
-
-    return SafeArea(
-      child: ListView(
-        children: [
-          ChoicePicker(
-            notchedTile: true,
-            choices: PieceShiftMethod.values,
-            selectedItem: pieceShiftMethod,
-            titleBuilder: (t) => Text(pieceShiftMethodl10n(context, t)),
-            onSelectedItemChanged: onChanged,
-          ),
-        ],
-      ),
-    );
-  }
-}
diff --git a/lib/src/view/settings/theme_screen.dart b/lib/src/view/settings/theme_screen.dart
index e0db758b15..8f34cc8d0d 100644
--- a/lib/src/view/settings/theme_screen.dart
+++ b/lib/src/view/settings/theme_screen.dart
@@ -80,7 +80,6 @@ class _Body extends ConsumerWidget {
                       ),
                     }.lock,
                     settings: boardPrefs.toBoardSettings().copyWith(
-                          enableCoordinates: true,
                           borderRadius:
                               const BorderRadius.all(Radius.circular(4.0)),
                           boxShadow: boardShadows,
@@ -151,6 +150,18 @@ class _Body extends ConsumerWidget {
                   );
                 },
               ),
+              SwitchSettingTile(
+                leading: const Icon(Icons.location_on),
+                title: Text(
+                  context.l10n.preferencesBoardCoordinates,
+                ),
+                value: boardPrefs.coordinates,
+                onChanged: (value) {
+                  ref
+                      .read(boardPreferencesProvider.notifier)
+                      .toggleCoordinates();
+                },
+              ),
               SwitchSettingTile(
                 // TODO translate
                 leading: const Icon(Icons.border_outer),
diff --git a/lib/src/view/study/study_bottom_bar.dart b/lib/src/view/study/study_bottom_bar.dart
index 815e54a97d..ade01ed711 100644
--- a/lib/src/view/study/study_bottom_bar.dart
+++ b/lib/src/view/study/study_bottom_bar.dart
@@ -1,9 +1,12 @@
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
 import 'package:lichess_mobile/src/model/common/id.dart';
 import 'package:lichess_mobile/src/model/study/study_controller.dart';
 import 'package:lichess_mobile/src/utils/l10n_context.dart';
+import 'package:lichess_mobile/src/utils/navigation.dart';
+import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
@@ -15,6 +18,25 @@ class StudyBottomBar extends ConsumerWidget {
 
   final StudyId id;
 
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final gamebook = ref.watch(
+      studyControllerProvider(id).select(
+        (s) => s.requireValue.gamebookActive,
+      ),
+    );
+
+    return gamebook ? _GamebookBottomBar(id: id) : _AnalysisBottomBar(id: id);
+  }
+}
+
+class _AnalysisBottomBar extends ConsumerWidget {
+  const _AnalysisBottomBar({
+    required this.id,
+  });
+
+  final StudyId id;
+
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final state = ref.watch(studyControllerProvider(id)).valueOrNull;
@@ -68,3 +90,97 @@ class StudyBottomBar extends ConsumerWidget {
     );
   }
 }
+
+class _GamebookBottomBar extends ConsumerWidget {
+  const _GamebookBottomBar({
+    required this.id,
+  });
+
+  final StudyId id;
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final state = ref.watch(studyControllerProvider(id)).requireValue;
+
+    return BottomBar(
+      children: [
+        ...switch (state.gamebookState) {
+          GamebookState.findTheMove => [
+              if (!state.currentNode.isRoot)
+                BottomBarButton(
+                  onTap: ref.read(studyControllerProvider(id).notifier).reset,
+                  icon: Icons.skip_previous,
+                  label: 'Back',
+                  showLabel: true,
+                ),
+              BottomBarButton(
+                icon: Icons.help,
+                label: context.l10n.viewTheSolution,
+                showLabel: true,
+                onTap: ref
+                    .read(studyControllerProvider(id).notifier)
+                    .showGamebookSolution,
+              ),
+            ],
+          GamebookState.startLesson || GamebookState.correctMove => [
+              BottomBarButton(
+                onTap: ref.read(studyControllerProvider(id).notifier).userNext,
+                icon: Icons.play_arrow,
+                label: context.l10n.studyNext,
+                showLabel: true,
+                blink: state.gamebookComment != null &&
+                    !state.isIntroductoryChapter,
+              ),
+            ],
+          GamebookState.incorrectMove => [
+              BottomBarButton(
+                onTap:
+                    ref.read(studyControllerProvider(id).notifier).userPrevious,
+                label: context.l10n.retry,
+                showLabel: true,
+                icon: Icons.refresh,
+                blink: state.gamebookComment != null,
+              ),
+            ],
+          GamebookState.lessonComplete => [
+              if (!state.isIntroductoryChapter)
+                BottomBarButton(
+                  onTap: ref.read(studyControllerProvider(id).notifier).reset,
+                  icon: Icons.refresh,
+                  label: context.l10n.studyPlayAgain,
+                  showLabel: true,
+                ),
+              BottomBarButton(
+                onTap: state.hasNextChapter
+                    ? ref.read(studyControllerProvider(id).notifier).nextChapter
+                    : null,
+                icon: Icons.play_arrow,
+                label: context.l10n.studyNextChapter,
+                showLabel: true,
+                blink: !state.isIntroductoryChapter && state.hasNextChapter,
+              ),
+              if (!state.isIntroductoryChapter)
+                BottomBarButton(
+                  onTap: () => pushPlatformRoute(
+                    context,
+                    rootNavigator: true,
+                    builder: (context) => AnalysisScreen(
+                      pgnOrId: state.pgn,
+                      options: AnalysisOptions(
+                        isLocalEvaluationAllowed: true,
+                        variant: state.variant,
+                        orientation: state.pov,
+                        id: standaloneAnalysisId,
+                      ),
+                    ),
+                  ),
+                  icon: Icons.biotech,
+                  label: context.l10n.analysis,
+                  showLabel: true,
+                ),
+            ],
+        },
+      ],
+    );
+  }
+}
diff --git a/lib/src/view/study/study_gamebook.dart b/lib/src/view/study/study_gamebook.dart
new file mode 100644
index 0000000000..67b6d17583
--- /dev/null
+++ b/lib/src/view/study/study_gamebook.dart
@@ -0,0 +1,174 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_linkify/flutter_linkify.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lichess_mobile/src/model/common/id.dart';
+import 'package:lichess_mobile/src/model/study/study_controller.dart';
+import 'package:lichess_mobile/src/utils/l10n_context.dart';
+import 'package:lichess_mobile/src/widgets/buttons.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+class StudyGamebook extends StatelessWidget {
+  const StudyGamebook(
+    this.id,
+  );
+
+  final StudyId id;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.all(5),
+      child: Column(
+        children: [
+          Expanded(
+            child: Card(
+              child: Padding(
+                padding: const EdgeInsets.all(10),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.stretch,
+                  children: [
+                    _Comment(id: id),
+                    _Hint(id: id),
+                  ],
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _Comment extends ConsumerWidget {
+  const _Comment({
+    required this.id,
+  });
+
+  final StudyId id;
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final state = ref.watch(studyControllerProvider(id)).requireValue;
+
+    final comment = state.gamebookComment ??
+        switch (state.gamebookState) {
+          GamebookState.findTheMove => context.l10n.studyWhatWouldYouPlay,
+          GamebookState.correctMove => context.l10n.studyGoodMove,
+          GamebookState.incorrectMove => context.l10n.puzzleNotTheMove,
+          GamebookState.lessonComplete =>
+            context.l10n.studyYouCompletedThisLesson,
+          _ => ''
+        };
+
+    return Expanded(
+      child: Scrollbar(
+        child: SingleChildScrollView(
+          child: Padding(
+            padding: const EdgeInsets.only(right: 5),
+            child: Linkify(
+              text: comment,
+              style: const TextStyle(
+                fontSize: 16,
+              ),
+              onOpen: (link) async {
+                launchUrl(Uri.parse(link.url));
+              },
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+class _Hint extends ConsumerStatefulWidget {
+  const _Hint({
+    required this.id,
+  });
+
+  final StudyId id;
+
+  @override
+  ConsumerState<_Hint> createState() => _HintState();
+}
+
+class _HintState extends ConsumerState<_Hint> {
+  bool showHint = false;
+
+  @override
+  Widget build(BuildContext context) {
+    final hint =
+        ref.watch(studyControllerProvider(widget.id)).requireValue.gamebookHint;
+    return hint == null
+        ? const SizedBox.shrink()
+        : SizedBox(
+            height: 40,
+            child: showHint
+                ? Center(child: Text(hint))
+                : TextButton(
+                    onPressed: () {
+                      setState(() {
+                        showHint = true;
+                      });
+                    },
+                    child: Text(context.l10n.getAHint),
+                  ),
+          );
+  }
+}
+
+class GamebookButton extends StatelessWidget {
+  const GamebookButton({
+    required this.icon,
+    required this.label,
+    required this.onTap,
+    this.highlighted = false,
+    super.key,
+  });
+
+  final IconData icon;
+  final String label;
+  final VoidCallback? onTap;
+
+  final bool highlighted;
+
+  bool get enabled => onTap != null;
+
+  @override
+  Widget build(BuildContext context) {
+    final primary = Theme.of(context).colorScheme.primary;
+
+    return Semantics(
+      container: true,
+      enabled: enabled,
+      button: true,
+      label: label,
+      excludeSemantics: true,
+      child: AdaptiveInkWell(
+        borderRadius: BorderRadius.zero,
+        onTap: onTap,
+        child: Opacity(
+          opacity: enabled ? 1.0 : 0.4,
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              Icon(icon, color: highlighted ? primary : null, size: 24),
+              Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 8.0),
+                child: Text(
+                  label,
+                  style: TextStyle(
+                    fontSize: 16.0,
+                    color: highlighted ? primary : null,
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/lib/src/view/study/study_screen.dart b/lib/src/view/study/study_screen.dart
index 3380854e23..7a1b246d6a 100644
--- a/lib/src/view/study/study_screen.dart
+++ b/lib/src/view/study/study_screen.dart
@@ -19,6 +19,7 @@ import 'package:lichess_mobile/src/utils/screen.dart';
 import 'package:lichess_mobile/src/view/engine/engine_gauge.dart';
 import 'package:lichess_mobile/src/view/engine/engine_lines.dart';
 import 'package:lichess_mobile/src/view/study/study_bottom_bar.dart';
+import 'package:lichess_mobile/src/view/study/study_gamebook.dart';
 import 'package:lichess_mobile/src/view/study/study_settings.dart';
 import 'package:lichess_mobile/src/view/study/study_tree_view.dart';
 import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
@@ -176,6 +177,11 @@ class _Body extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final gamebookActive = ref.watch(
+      studyControllerProvider(id)
+          .select((state) => state.requireValue.gamebookActive),
+    );
+
     return SafeArea(
       child: Column(
         children: [
@@ -229,6 +235,9 @@ class _Body extends ConsumerWidget {
                           )
                         : null;
 
+                final bottomChild =
+                    gamebookActive ? StudyGamebook(id) : StudyTreeView(id);
+
                 return landscape
                     ? Row(
                         mainAxisSize: MainAxisSize.max,
@@ -267,7 +276,7 @@ class _Body extends ConsumerWidget {
                                       kTabletBoardTableSidePadding,
                                     ),
                                     semanticContainer: false,
-                                    child: StudyTreeView(id),
+                                    child: bottomChild,
                                   ),
                                 ),
                               ],
@@ -305,7 +314,7 @@ class _Body extends ConsumerWidget {
                                       horizontal: kTabletBoardTableSidePadding,
                                     )
                                   : EdgeInsets.zero,
-                              child: StudyTreeView(id),
+                              child: bottomChild,
                             ),
                           ),
                         ],
@@ -384,6 +393,7 @@ class _StudyBoardState extends ConsumerState<_StudyBoard> {
             (prefs) => prefs.showVariationArrows,
           ),
         ) &&
+        !studyState.gamebookActive &&
         currentNode.children.length > 1;
 
     final pgnShapes = ISet(
diff --git a/lib/src/view/tools/tools_tab_screen.dart b/lib/src/view/tools/tools_tab_screen.dart
index 77765f9282..8270f84bcc 100644
--- a/lib/src/view/tools/tools_tab_screen.dart
+++ b/lib/src/view/tools/tools_tab_screen.dart
@@ -12,7 +12,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart';
 import 'package:lichess_mobile/src/utils/navigation.dart';
 import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
 import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart';
-import 'package:lichess_mobile/src/view/clock/clock_screen.dart';
+import 'package:lichess_mobile/src/view/clock/clock_tool_screen.dart';
 import 'package:lichess_mobile/src/view/coordinate_training/coordinate_training_screen.dart';
 import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_screen.dart';
 import 'package:lichess_mobile/src/view/study/study_list_screen.dart';
@@ -209,7 +209,7 @@ class _Body extends ConsumerWidget {
             title: context.l10n.clock,
             onTap: () => pushPlatformRoute(
               context,
-              builder: (context) => const ClockScreen(),
+              builder: (context) => const ClockToolScreen(),
               rootNavigator: true,
             ),
           ),
diff --git a/lib/src/view/watch/tv_screen.dart b/lib/src/view/watch/tv_screen.dart
index 5d55705d7e..98df340be2 100644
--- a/lib/src/view/watch/tv_screen.dart
+++ b/lib/src/view/watch/tv_screen.dart
@@ -13,7 +13,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
 import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
 import 'package:lichess_mobile/src/widgets/buttons.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 import 'package:lichess_mobile/src/widgets/platform_scaffold.dart';
 
 class TvScreen extends ConsumerStatefulWidget {
@@ -92,10 +92,19 @@ class _Body extends ConsumerWidget {
                 final blackPlayerWidget = GamePlayer(
                   player: game.black.setOnGame(true),
                   clock: gameState.game.clock != null
-                      ? CountdownClock(
+                      ? CountdownClockBuilder(
                           key: blackClockKey,
-                          duration: gameState.game.clock!.black,
+                          timeLeft: gameState.game.clock!.black,
+                          delay: gameState.game.clock!.lag ??
+                              const Duration(milliseconds: 10),
+                          clockUpdatedAt: gameState.game.clock!.at,
                           active: gameState.activeClockSide == Side.black,
+                          builder: (context, timeLeft) {
+                            return Clock(
+                              timeLeft: timeLeft,
+                              active: gameState.activeClockSide == Side.black,
+                            );
+                          },
                         )
                       : null,
                   materialDiff: game.lastMaterialDiffAt(Side.black),
@@ -103,10 +112,19 @@ class _Body extends ConsumerWidget {
                 final whitePlayerWidget = GamePlayer(
                   player: game.white.setOnGame(true),
                   clock: gameState.game.clock != null
-                      ? CountdownClock(
+                      ? CountdownClockBuilder(
                           key: whiteClockKey,
-                          duration: gameState.game.clock!.white,
+                          timeLeft: gameState.game.clock!.white,
+                          clockUpdatedAt: gameState.game.clock!.at,
+                          delay: gameState.game.clock!.lag ??
+                              const Duration(milliseconds: 10),
                           active: gameState.activeClockSide == Side.white,
+                          builder: (context, timeLeft) {
+                            return Clock(
+                              timeLeft: timeLeft,
+                              active: gameState.activeClockSide == Side.white,
+                            );
+                          },
                         )
                       : null,
                   materialDiff: game.lastMaterialDiffAt(Side.white),
diff --git a/lib/src/widgets/board_table.dart b/lib/src/widgets/board_table.dart
index c9cba27a51..90681e2455 100644
--- a/lib/src/widgets/board_table.dart
+++ b/lib/src/widgets/board_table.dart
@@ -267,7 +267,7 @@ class _BoardTableState extends ConsumerState<BoardTable> {
                         crossAxisAlignment: CrossAxisAlignment.stretch,
                         mainAxisAlignment: MainAxisAlignment.spaceAround,
                         children: [
-                          Flexible(child: widget.topTable),
+                          widget.topTable,
                           if (!widget.zenMode && slicedMoves != null)
                             Expanded(
                               child: Padding(
@@ -282,14 +282,8 @@ class _BoardTableState extends ConsumerState<BoardTable> {
                               ),
                             )
                           else
-                            // same height as [MoveList]
-                            const Expanded(
-                              child: Padding(
-                                padding: EdgeInsets.all(16.0),
-                                child: SizedBox(height: 40),
-                              ),
-                            ),
-                          Flexible(child: widget.bottomTable),
+                            const Spacer(),
+                          widget.bottomTable,
                         ],
                       ),
                     ),
diff --git a/lib/src/widgets/countdown_clock.dart b/lib/src/widgets/clock.dart
similarity index 64%
rename from lib/src/widgets/countdown_clock.dart
rename to lib/src/widgets/clock.dart
index cfae7ca1ce..7de23af3e0 100644
--- a/lib/src/widgets/countdown_clock.dart
+++ b/lib/src/widgets/clock.dart
@@ -1,170 +1,23 @@
 import 'dart:async';
 
+import 'package:clock/clock.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:lichess_mobile/src/constants.dart';
-import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
 import 'package:lichess_mobile/src/utils/screen.dart';
 
-/// A simple countdown clock.
-///
-/// The clock starts only when [active] is `true`.
-class CountdownClock extends ConsumerStatefulWidget {
-  const CountdownClock({
-    required this.duration,
-    required this.active,
-    this.emergencyThreshold,
-    this.emergencySoundEnabled = true,
-    this.onFlag,
-    this.onStop,
-    this.clockStyle,
-    this.padLeft = false,
-    super.key,
-  });
-
-  /// The duration left on the clock.
-  final Duration duration;
-
-  /// If [timeLeft] is less than [emergencyThreshold], the clock will change
-  /// its background color to [ClockStyle.emergencyBackgroundColor] activeBackgroundColor
-  /// If [emergencySoundEnabled] is `true`, the clock will also play a sound.
-  final Duration? emergencyThreshold;
-
-  /// Whether to play an emergency sound when the clock reaches the emergency
-  final bool emergencySoundEnabled;
-
-  /// If [active] is `true`, the clock starts counting down.
-  final bool active;
-
-  /// Callback when the clock reaches zero.
-  final VoidCallback? onFlag;
-
-  /// Callback with the remaining duration when the clock stops
-  final ValueSetter<Duration>? onStop;
-
-  /// Custom color style
-  final ClockStyle? clockStyle;
-
-  /// Whether to pad with a leading zero (default is `false`).
-  final bool padLeft;
-
-  @override
-  ConsumerState<CountdownClock> createState() => _CountdownClockState();
-}
-
-const _period = Duration(milliseconds: 100);
-const _emergencyDelay = Duration(seconds: 20);
-
-class _CountdownClockState extends ConsumerState<CountdownClock> {
-  Timer? _timer;
-  Duration timeLeft = Duration.zero;
-  bool _shouldPlayEmergencyFeedback = true;
-  DateTime? _nextEmergency;
-
-  final _stopwatch = Stopwatch();
-
-  void startClock() {
-    _timer?.cancel();
-    _stopwatch.reset();
-    _stopwatch.start();
-    _timer = Timer.periodic(_period, (timer) {
-      setState(() {
-        timeLeft = timeLeft - _stopwatch.elapsed;
-        _stopwatch.reset();
-        _playEmergencyFeedback();
-        if (timeLeft <= Duration.zero) {
-          widget.onFlag?.call();
-          timeLeft = Duration.zero;
-          stopClock();
-        }
-      });
-    });
-  }
-
-  void stopClock() {
-    setState(() {
-      timeLeft = timeLeft - _stopwatch.elapsed;
-      if (timeLeft < Duration.zero) {
-        timeLeft = Duration.zero;
-      }
-    });
-    _timer?.cancel();
-    _stopwatch.stop();
-    scheduleMicrotask(() {
-      widget.onStop?.call(timeLeft);
-    });
-  }
-
-  void _playEmergencyFeedback() {
-    if (widget.emergencyThreshold != null &&
-        timeLeft <= widget.emergencyThreshold! &&
-        _shouldPlayEmergencyFeedback &&
-        (_nextEmergency == null || _nextEmergency!.isBefore(DateTime.now()))) {
-      _shouldPlayEmergencyFeedback = false;
-      _nextEmergency = DateTime.now().add(_emergencyDelay);
-      if (widget.emergencySoundEnabled) {
-        ref.read(soundServiceProvider).play(Sound.lowTime);
-      }
-      HapticFeedback.heavyImpact();
-    } else if (widget.emergencyThreshold != null &&
-        timeLeft > widget.emergencyThreshold! * 1.5) {
-      _shouldPlayEmergencyFeedback = true;
-    }
-  }
-
-  @override
-  void initState() {
-    super.initState();
-    timeLeft = widget.duration;
-    if (widget.active) {
-      startClock();
-    }
-  }
-
-  @override
-  void didUpdateWidget(CountdownClock oldClock) {
-    super.didUpdateWidget(oldClock);
-    if (widget.duration != oldClock.duration) {
-      timeLeft = widget.duration;
-    }
-
-    if (widget.active != oldClock.active) {
-      widget.active ? startClock() : stopClock();
-    }
-  }
-
-  @override
-  void dispose() {
-    super.dispose();
-    _timer?.cancel();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return RepaintBoundary(
-      child: Clock(
-        padLeft: widget.padLeft,
-        timeLeft: timeLeft,
-        active: widget.active,
-        emergencyThreshold: widget.emergencyThreshold,
-        clockStyle: widget.clockStyle,
-      ),
-    );
-  }
-}
-
 const _kClockFontSize = 26.0;
 const _kClockTenthFontSize = 20.0;
 const _kClockHundredsFontSize = 18.0;
 
+const _showTenthsThreshold = Duration(seconds: 10);
+
 /// A stateless widget that displays the time left on the clock.
 ///
-/// For a clock widget that automatically counts down, see [CountdownClock].
+/// For a clock widget that automatically counts down, see [CountdownClockBuilder].
 class Clock extends StatelessWidget {
   const Clock({
     required this.timeLeft,
-    required this.active,
+    this.active = false,
     this.clockStyle,
     this.emergencyThreshold,
     this.padLeft = false,
@@ -192,7 +45,7 @@ class Clock extends StatelessWidget {
     final hours = timeLeft.inHours;
     final mins = timeLeft.inMinutes.remainder(60);
     final secs = timeLeft.inSeconds.remainder(60).toString().padLeft(2, '0');
-    final showTenths = timeLeft < const Duration(seconds: 10);
+    final showTenths = timeLeft < _showTenthsThreshold;
     final isEmergency =
         emergencyThreshold != null && timeLeft <= emergencyThreshold!;
     final remainingHeight = estimateRemainingHeightLeftBoard(context);
@@ -310,3 +163,154 @@ class ClockStyle {
     emergencyBackgroundColor: Color(0xFFF2CCCC),
   );
 }
+
+typedef ClockWidgetBuilder = Widget Function(BuildContext, Duration);
+
+/// A widget that automatically starts a countdown from [timeLeft] when [active] is `true`.
+///
+/// The clock will update the UI every [tickInterval], which defaults to 100ms,
+/// and the [builder] will be called with the new [timeLeft] value.
+///
+/// The clock can be synchronized with the time at which the clock event was received from the server
+/// by setting the [clockUpdatedAt] parameter.
+/// This widget will only update its internal clock when the [clockUpdatedAt] parameter changes.
+///
+/// The [delay] parameter can be used to delay the start of the clock.
+///
+/// The clock will stop counting down when [active] is set to `false`.
+///
+/// The clock will stop counting down when the time left reaches zero.
+class CountdownClockBuilder extends StatefulWidget {
+  const CountdownClockBuilder({
+    required this.timeLeft,
+    required this.active,
+    required this.builder,
+    this.delay,
+    this.tickInterval = const Duration(milliseconds: 100),
+    this.clockUpdatedAt,
+    super.key,
+  });
+
+  /// The duration left on the clock.
+  final Duration timeLeft;
+
+  /// The delay before the clock starts counting down.
+  ///
+  /// This can be used to implement lag compensation.
+  final Duration? delay;
+
+  /// The interval at which the clock updates the UI.
+  final Duration tickInterval;
+
+  /// The time at which the clock was updated.
+  ///
+  /// Use this parameter to synchronize the clock with the time at which the clock
+  /// event was received from the server and to compensate for UI lag.
+  final DateTime? clockUpdatedAt;
+
+  /// If `true`, the clock starts counting down.
+  final bool active;
+
+  /// A [ClockWidgetBuilder] that builds the clock on each tick with the new [timeLeft] value.
+  final ClockWidgetBuilder builder;
+
+  @override
+  State<CountdownClockBuilder> createState() => _CountdownClockState();
+}
+
+class _CountdownClockState extends State<CountdownClockBuilder> {
+  Timer? _timer;
+  Timer? _delayTimer;
+  Duration timeLeft = Duration.zero;
+
+  final _stopwatch = clock.stopwatch();
+
+  void startClock() {
+    final now = clock.now();
+    final delay = widget.delay ?? Duration.zero;
+    final clockUpdatedAt = widget.clockUpdatedAt ?? now;
+    // UI lag diff: the elapsed time between the time the clock should have started
+    // and the time the clock is actually started
+    final uiLag = now.difference(clockUpdatedAt);
+    final realDelay = delay - uiLag;
+
+    // real delay is negative, we need to adjust the timeLeft.
+    if (realDelay < Duration.zero) {
+      final newTimeLeft = timeLeft + realDelay;
+      timeLeft = newTimeLeft > Duration.zero ? newTimeLeft : Duration.zero;
+    }
+
+    if (realDelay > Duration.zero) {
+      _delayTimer?.cancel();
+      _delayTimer = Timer(realDelay, _scheduleTick);
+    } else {
+      _scheduleTick();
+    }
+  }
+
+  void _scheduleTick() {
+    _timer?.cancel();
+    _timer = Timer(widget.tickInterval, _tick);
+    _stopwatch.reset();
+    _stopwatch.start();
+  }
+
+  void _tick() {
+    final newTimeLeft = timeLeft - _stopwatch.elapsed;
+    setState(() {
+      timeLeft = newTimeLeft;
+      if (timeLeft <= Duration.zero) {
+        timeLeft = Duration.zero;
+      }
+    });
+    if (timeLeft > Duration.zero) {
+      _scheduleTick();
+    }
+  }
+
+  void stopClock() {
+    _delayTimer?.cancel();
+    _timer?.cancel();
+    _stopwatch.stop();
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    timeLeft = widget.timeLeft;
+    if (widget.active) {
+      startClock();
+    }
+  }
+
+  @override
+  void didUpdateWidget(CountdownClockBuilder oldClock) {
+    super.didUpdateWidget(oldClock);
+
+    if (widget.clockUpdatedAt != oldClock.clockUpdatedAt) {
+      timeLeft = widget.timeLeft;
+    }
+
+    if (widget.active != oldClock.active) {
+      if (widget.active) {
+        startClock();
+      } else {
+        stopClock();
+      }
+    }
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+    _delayTimer?.cancel();
+    _timer?.cancel();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return RepaintBoundary(
+      child: widget.builder(context, timeLeft),
+    );
+  }
+}
diff --git a/lib/src/widgets/rating.dart b/lib/src/widgets/rating.dart
index 69df2388c9..fae2ea6472 100644
--- a/lib/src/widgets/rating.dart
+++ b/lib/src/widgets/rating.dart
@@ -19,10 +19,8 @@ class RatingWidget extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    final ratingStr =
-        rating is double ? rating.toStringAsFixed(2) : rating.toString();
     return Text(
-      '$ratingStr${provisional == true || deviation > kProvisionalDeviation ? '?' : ''}',
+      '${rating.round()}${provisional == true || deviation > kProvisionalDeviation ? '?' : ''}',
       style: style,
     );
   }
diff --git a/test/model/challenge/challenge_service_test.dart b/test/model/challenge/challenge_service_test.dart
index 10872457a1..25c637853a 100644
--- a/test/model/challenge/challenge_service_test.dart
+++ b/test/model/challenge/challenge_service_test.dart
@@ -32,7 +32,7 @@ void main() {
   test('exposes a challenges stream', () async {
     final fakeChannel = FakeWebSocketChannel();
     final socketClient =
-        makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel));
+        makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel));
     await socketClient.connect();
     await socketClient.firstConnection;
 
@@ -118,7 +118,7 @@ void main() {
     fakeAsync((async) {
       final fakeChannel = FakeWebSocketChannel();
       final socketClient =
-          makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel));
+          makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel));
       socketClient.connect();
       notificationService.start();
       challengeService.start();
@@ -221,7 +221,7 @@ void main() {
     fakeAsync((async) {
       final fakeChannel = FakeWebSocketChannel();
       final socketClient =
-          makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel));
+          makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel));
       socketClient.connect();
       notificationService.start();
       challengeService.start();
diff --git a/test/model/clock/chess_clock_test.dart b/test/model/clock/chess_clock_test.dart
new file mode 100644
index 0000000000..f0c2a45870
--- /dev/null
+++ b/test/model/clock/chess_clock_test.dart
@@ -0,0 +1,231 @@
+import 'package:dartchess/dartchess.dart';
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lichess_mobile/src/model/clock/chess_clock.dart';
+
+void main() {
+  test('make clock', () {
+    final clock = ChessClock(
+      whiteTime: const Duration(seconds: 5),
+      blackTime: const Duration(seconds: 5),
+    );
+    expect(clock.isRunning, false);
+    expect(clock.whiteTime.value, const Duration(seconds: 5));
+    expect(clock.blackTime.value, const Duration(seconds: 5));
+  });
+
+  test('start clock', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.isRunning, true);
+    });
+  });
+
+  test('clock ticking', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 4));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 3));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+    });
+  });
+
+  test('stop clock', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.isRunning, true);
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 4));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      final thinkTime = clock.stop();
+      expect(clock.isRunning, false);
+      expect(thinkTime, const Duration(seconds: 1));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 4));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+    });
+  });
+
+  test('start side', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      final thinkTime = clock.startSide(Side.black);
+      expect(thinkTime, null);
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 4));
+    });
+  });
+
+  test('start side (running clock)', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.isRunning, true);
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 4));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      final thinkTime = clock.startSide(Side.black);
+      expect(thinkTime, const Duration(seconds: 1));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 4));
+      expect(clock.blackTime.value, const Duration(seconds: 4));
+    });
+  });
+
+  test('start side (running clock, same side)', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.isRunning, true);
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 4));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      final thinkTime = clock.startSide(Side.white);
+      expect(thinkTime, const Duration(seconds: 1));
+      async.elapse(const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 3));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+    });
+  });
+
+  test('start with delay', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start(delay: const Duration(milliseconds: 20));
+      expect(clock.isRunning, true);
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(milliseconds: 10));
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      // the start delay is reached, but clock not updated yet since tick delay is 100ms
+      async.elapse(const Duration(milliseconds: 100));
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(milliseconds: 10));
+      expect(clock.whiteTime.value, const Duration(milliseconds: 4900));
+      final thinkTime = clock.stop();
+      expect(thinkTime, const Duration(milliseconds: 100));
+    });
+  });
+
+  test('increment times', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      clock.incTimes(whiteInc: const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 6));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      clock.incTimes(blackInc: const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 6));
+      expect(clock.blackTime.value, const Duration(seconds: 6));
+    });
+  });
+
+  test('increment specific side', () {
+    fakeAsync((async) {
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+      );
+      clock.start();
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      clock.incTime(Side.white, const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 6));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      clock.incTime(Side.black, const Duration(seconds: 1));
+      expect(clock.whiteTime.value, const Duration(seconds: 6));
+      expect(clock.blackTime.value, const Duration(seconds: 6));
+    });
+  });
+
+  test('flag', () {
+    fakeAsync((async) {
+      int flagCount = 0;
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+        onFlag: () {
+          flagCount++;
+        },
+      );
+      clock.start();
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 5));
+      expect(flagCount, 1);
+      expect(clock.whiteTime.value, Duration.zero);
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+
+      // continue ticking and calling onFlag
+      async.elapse(const Duration(milliseconds: 200));
+      expect(flagCount, 3);
+      clock.stop();
+
+      // no more onFlag calls
+      async.elapse(const Duration(seconds: 5));
+      expect(flagCount, 3);
+    });
+  });
+
+  test('onEmergency', () {
+    fakeAsync((async) {
+      int onEmergencyCount = 0;
+      final clock = ChessClock(
+        whiteTime: const Duration(seconds: 5),
+        blackTime: const Duration(seconds: 5),
+        emergencyThreshold: const Duration(seconds: 2),
+        onEmergency: (_) {
+          onEmergencyCount++;
+        },
+      );
+      clock.start();
+      expect(clock.whiteTime.value, const Duration(seconds: 5));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 2));
+      expect(clock.whiteTime.value, const Duration(seconds: 3));
+      expect(clock.blackTime.value, const Duration(seconds: 5));
+      async.elapse(const Duration(seconds: 1));
+      expect(onEmergencyCount, 1);
+      async.elapse(const Duration(milliseconds: 100));
+      expect(onEmergencyCount, 1);
+    });
+  });
+}
diff --git a/test/model/game/game_socket_events_test.dart b/test/model/game/game_socket_events_test.dart
index 12d4ee32a4..7bb4597c5c 100644
--- a/test/model/game/game_socket_events_test.dart
+++ b/test/model/game/game_socket_events_test.dart
@@ -15,12 +15,17 @@ void main() {
     final game = fullEvent.game;
     expect(game.id, const GameId('nV3DaALy'));
     expect(
-      game.clock,
-      const PlayableClockData(
-        running: true,
-        white: Duration(seconds: 149, milliseconds: 50),
-        black: Duration(seconds: 775, milliseconds: 940),
-      ),
+      game.clock?.running,
+      true,
+    );
+    expect(
+      game.clock?.white,
+      const Duration(seconds: 149, milliseconds: 50),
+    );
+
+    expect(
+      game.clock?.black,
+      const Duration(seconds: 775, milliseconds: 940),
     );
     expect(
       game.meta,
diff --git a/test/model/game/game_socket_example_data.dart b/test/model/game/game_socket_example_data.dart
new file mode 100644
index 0000000000..f4f3cc4c95
--- /dev/null
+++ b/test/model/game/game_socket_example_data.dart
@@ -0,0 +1,103 @@
+import 'package:dartchess/dartchess.dart';
+import 'package:lichess_mobile/src/model/common/id.dart';
+
+typedef FullEventTestClock = ({
+  bool running,
+  Duration initial,
+  Duration increment,
+  Duration? emerg,
+  Duration white,
+  Duration black,
+});
+
+String makeFullEvent(
+  GameId id,
+  String pgn, {
+  required String whiteUserName,
+  required String blackUserName,
+  int socketVersion = 0,
+  Side? youAre,
+  FullEventTestClock clock = const (
+    running: false,
+    initial: Duration(minutes: 3),
+    increment: Duration(seconds: 2),
+    emerg: Duration(seconds: 30),
+    white: Duration(minutes: 3),
+    black: Duration(minutes: 3),
+  ),
+}) {
+  final youAreStr = youAre != null ? '"youAre": "${youAre.name}",' : '';
+  return '''
+{
+  "t": "full",
+  "d": {
+    "game": {
+      "id": "$id",
+        "variant": {
+          "key": "standard",
+          "name": "Standard",
+          "short": "Std"
+        },
+        "speed": "blitz",
+        "perf": "blitz",
+        "rated": false,
+        "source": "lobby",
+        "status": {
+          "id": 20,
+          "name": "started"
+        },
+        "createdAt": 1685698678928,
+        "pgn": "$pgn"
+    },
+    "white": {
+      "user": {
+        "name": "$whiteUserName",
+        "patron": true,
+        "id": "${whiteUserName.toLowerCase()}"
+      },
+      "rating": 1806,
+      "provisional": true,
+      "onGame": true
+    },
+    "black": {
+      "user": {
+        "name": "$blackUserName",
+        "patron": true,
+        "id": "${blackUserName.toLowerCase()}"
+      },
+      "onGame": true
+    },
+    $youAreStr
+    "socket": $socketVersion,
+    "clock": {
+      "running": ${clock.running},
+      "initial": ${clock.initial.inSeconds},
+      "increment": ${clock.increment.inSeconds},
+      "white": ${(clock.white.inMilliseconds / 1000).toStringAsFixed(2)},
+      "black": ${(clock.black.inMilliseconds / 1000).toStringAsFixed(2)},
+      "emerg": 30,
+      "moretime": 15
+    },
+    "expiration": {
+      "idleMillis": 245,
+      "millisToMove": 30000
+    },
+    "chat": {
+      "lines": [
+        {
+          "u": "Zidrox",
+          "t": "Good luck",
+          "f": "people.man-singer"
+        },
+        {
+          "u": "lichess",
+          "t": "Takeback accepted",
+          "f": "activity.lichess"
+        }
+      ]
+    }
+  },
+  "v": $socketVersion
+}
+''';
+}
diff --git a/test/network/fake_websocket_channel.dart b/test/network/fake_websocket_channel.dart
index 1d639eb2f2..88e3391d04 100644
--- a/test/network/fake_websocket_channel.dart
+++ b/test/network/fake_websocket_channel.dart
@@ -7,7 +7,7 @@ import 'package:stream_channel/stream_channel.dart';
 import 'package:web_socket_channel/web_socket_channel.dart';
 
 class FakeWebSocketChannelFactory implements WebSocketChannelFactory {
-  final FutureOr<WebSocketChannel> Function() createFunction;
+  final FutureOr<WebSocketChannel> Function(String url) createFunction;
 
   const FakeWebSocketChannelFactory(this.createFunction);
 
@@ -17,7 +17,7 @@ class FakeWebSocketChannelFactory implements WebSocketChannelFactory {
     Map<String, dynamic>? headers,
     Duration timeout = const Duration(seconds: 1),
   }) async {
-    return createFunction();
+    return createFunction(url);
   }
 }
 
@@ -30,11 +30,19 @@ class FakeWebSocketChannelFactory implements WebSocketChannelFactory {
 /// behavior can be changed by setting [shouldSendPong] to false.
 ///
 /// It also allows to increase the lag of the connection by setting the
-/// [connectionLag] property.
+/// [connectionLag] property. By default [connectionLag] is set to [Duration.zero]
+/// to simplify testing.
+/// When lag is 0, the pong response will be sent in the next microtask.
 ///
 /// The [sentMessages] and [sentMessagesExceptPing] streams can be used to
 /// verify that the client sends the expected messages.
 class FakeWebSocketChannel implements WebSocketChannel {
+  FakeWebSocketChannel({this.connectionLag = Duration.zero});
+
+  int _pongCount = 0;
+
+  final _connectionCompleter = Completer<void>();
+
   static bool isPing(dynamic data) {
     if (data is! String) {
       return false;
@@ -55,13 +63,19 @@ class FakeWebSocketChannel implements WebSocketChannel {
   /// The controller for outgoing (to server) messages.
   final _outcomingController = StreamController<dynamic>.broadcast();
 
+  /// The lag of the connection (duration before pong response) in milliseconds.
+  Duration connectionLag;
+
   /// Whether the server should send a pong response to a ping request.
   ///
   /// Can be used to simulate a faulty connection.
   bool shouldSendPong = true;
 
-  /// The lag of the connection (duration before pong response) in milliseconds.
-  Duration connectionLag = const Duration(milliseconds: 10);
+  /// Number of pong response received
+  int get pongCount => _pongCount;
+
+  /// A Future that resolves when the first pong message is received
+  Future<void> get connectionEstablished => _connectionCompleter.future;
 
   /// The stream of all outgoing messages.
   Stream<dynamic> get sentMessages => _outcomingController.stream;
@@ -157,12 +171,22 @@ class FakeWebSocketSink implements WebSocketSink {
 
     // Simulates pong response if connection is not closed
     if (_channel.shouldSendPong && FakeWebSocketChannel.isPing(data)) {
-      Future<void>.delayed(_channel.connectionLag, () {
+      void sendPong() {
         if (_channel._incomingController.isClosed) {
           return;
         }
+        _channel._pongCount++;
+        if (_channel._pongCount == 1) {
+          _channel._connectionCompleter.complete();
+        }
         _channel._incomingController.add('0');
-      });
+      }
+
+      if (_channel.connectionLag > Duration.zero) {
+        Future<void>.delayed(_channel.connectionLag, sendPong);
+      } else {
+        scheduleMicrotask(sendPong);
+      }
     }
   }
 
diff --git a/test/network/socket_test.dart b/test/network/socket_test.dart
index 86e5d34d80..c5cbe7d5d2 100644
--- a/test/network/socket_test.dart
+++ b/test/network/socket_test.dart
@@ -45,7 +45,7 @@ void main() {
       final fakeChannel = FakeWebSocketChannel();
 
       final socketClient =
-          makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel));
+          makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel));
       socketClient.connect();
 
       int sentPingCount = 0;
@@ -69,7 +69,7 @@ void main() {
     test('reconnects when connection attempt fails', () async {
       int numConnectionAttempts = 0;
 
-      final fakeChannelFactory = FakeWebSocketChannelFactory(() {
+      final fakeChannelFactory = FakeWebSocketChannelFactory((_) {
         numConnectionAttempts++;
         if (numConnectionAttempts == 1) {
           throw const SocketException('Connection failed');
@@ -95,7 +95,7 @@ void main() {
       // channels per connection attempt
       final Map<int, FakeWebSocketChannel> channels = {};
 
-      final fakeChannelFactory = FakeWebSocketChannelFactory(() {
+      final fakeChannelFactory = FakeWebSocketChannelFactory((_) {
         numConnectionAttempts++;
         final channel = FakeWebSocketChannel();
         int sentPingCount = 0;
@@ -133,10 +133,11 @@ void main() {
     });
 
     test('computes average lag', () async {
-      final fakeChannel = FakeWebSocketChannel();
+      final fakeChannel =
+          FakeWebSocketChannel(connectionLag: const Duration(milliseconds: 10));
 
       final socketClient =
-          makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel));
+          makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel));
       socketClient.connect();
 
       // before the connection is ready the average lag is zero
@@ -188,7 +189,7 @@ void main() {
       final fakeChannel = FakeWebSocketChannel();
 
       final socketClient =
-          makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel));
+          makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel));
       socketClient.connect();
 
       await socketClient.firstConnection;
diff --git a/test/test_container.dart b/test/test_container.dart
index ecb340ee0a..7e1ab64506 100644
--- a/test/test_container.dart
+++ b/test/test_container.dart
@@ -68,7 +68,7 @@ Future<ProviderContainer> makeContainer({
         return db;
       }),
       webSocketChannelFactoryProvider.overrideWith((ref) {
-        return FakeWebSocketChannelFactory(() => FakeWebSocketChannel());
+        return FakeWebSocketChannelFactory((_) => FakeWebSocketChannel());
       }),
       socketPoolProvider.overrideWith((ref) {
         final pool = SocketPool(ref);
diff --git a/test/test_helpers.dart b/test/test_helpers.dart
index 448d85578c..dfedcc6b3c 100644
--- a/test/test_helpers.dart
+++ b/test/test_helpers.dart
@@ -1,5 +1,6 @@
 import 'dart:convert';
 
+import 'package:chessground/chessground.dart';
 import 'package:dartchess/dartchess.dart';
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/foundation.dart';
@@ -114,17 +115,18 @@ Offset squareOffset(
 /// Plays a move on the board.
 Future<void> playMove(
   WidgetTester tester,
-  Rect boardRect,
   String from,
   String to, {
+  Rect? boardRect,
   Side orientation = Side.white,
 }) async {
+  final rect = boardRect ?? tester.getRect(find.byType(Chessboard));
   await tester.tapAt(
-    squareOffset(Square.fromName(from), boardRect, orientation: orientation),
+    squareOffset(Square.fromName(from), rect, orientation: orientation),
   );
   await tester.pump();
   await tester.tapAt(
-    squareOffset(Square.fromName(to), boardRect, orientation: orientation),
+    squareOffset(Square.fromName(to), rect, orientation: orientation),
   );
   await tester.pump();
 }
diff --git a/test/test_provider_scope.dart b/test/test_provider_scope.dart
index 8410a60edc..81419fd5a0 100644
--- a/test/test_provider_scope.dart
+++ b/test/test_provider_scope.dart
@@ -183,7 +183,7 @@ Future<Widget> makeTestProviderScope(
       }),
       // ignore: scoped_providers_should_specify_dependencies
       webSocketChannelFactoryProvider.overrideWith((ref) {
-        return FakeWebSocketChannelFactory(() => FakeWebSocketChannel());
+        return FakeWebSocketChannelFactory((_) => FakeWebSocketChannel());
       }),
       // ignore: scoped_providers_should_specify_dependencies
       socketPoolProvider.overrideWith((ref) {
diff --git a/test/view/game/game_screen_test.dart b/test/view/game/game_screen_test.dart
new file mode 100644
index 0000000000..18d2c45d88
--- /dev/null
+++ b/test/view/game/game_screen_test.dart
@@ -0,0 +1,497 @@
+import 'package:chessground/chessground.dart';
+import 'package:dartchess/dartchess.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/testing.dart';
+import 'package:lichess_mobile/src/model/common/id.dart';
+import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
+import 'package:lichess_mobile/src/model/lobby/game_seek.dart';
+import 'package:lichess_mobile/src/network/http.dart';
+import 'package:lichess_mobile/src/network/socket.dart';
+import 'package:lichess_mobile/src/view/game/game_screen.dart';
+import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../model/game/game_socket_example_data.dart';
+import '../../network/fake_websocket_channel.dart';
+import '../../test_helpers.dart';
+import '../../test_provider_scope.dart';
+
+final client = MockClient((request) {
+  if (request.url.path == '/api/board/seek') {
+    return mockResponse('ok', 200);
+  }
+  return mockResponse('', 404);
+});
+
+class MockSoundService extends Mock implements SoundService {}
+
+void main() {
+  group('Loading', () {
+    testWidgets('a game directly with initialGameId',
+        (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+
+      final app = await makeTestProviderScopeApp(
+        tester,
+        home: const GameScreen(
+          initialGameId: GameFullId('qVChCOTcHSeW'),
+        ),
+        overrides: [
+          lichessClientProvider
+              .overrideWith((ref) => LichessClient(client, ref)),
+          webSocketChannelFactoryProvider.overrideWith((ref) {
+            return FakeWebSocketChannelFactory((_) => fakeSocket);
+          }),
+        ],
+      );
+      await tester.pumpWidget(app);
+
+      // while loading, displays an empty board
+      expect(find.byType(Chessboard), findsOneWidget);
+      expect(find.byType(PieceWidget), findsNothing);
+
+      // now the game controller is loading and screen doesn't have changed yet
+      await tester.pump(const Duration(milliseconds: 10));
+      expect(find.byType(Chessboard), findsOneWidget);
+      expect(find.byType(PieceWidget), findsNothing);
+
+      await fakeSocket.connectionEstablished;
+
+      fakeSocket.addIncomingMessages([
+        makeFullEvent(
+          const GameId('qVChCOTc'),
+          '',
+          whiteUserName: 'Peter',
+          blackUserName: 'Steven',
+        ),
+      ]);
+      // wait for socket message
+      await tester.pump(const Duration(milliseconds: 10));
+
+      expect(find.byType(PieceWidget), findsNWidgets(32));
+      expect(find.text('Peter'), findsOneWidget);
+      expect(find.text('Steven'), findsOneWidget);
+    });
+
+    testWidgets('a game from the pool with a seek',
+        (WidgetTester tester) async {
+      final fakeLobbySocket = FakeWebSocketChannel();
+      final fakeGameSocket = FakeWebSocketChannel();
+
+      final app = await makeTestProviderScopeApp(
+        tester,
+        home: const GameScreen(
+          seek: GameSeek(
+            clock: (Duration(minutes: 3), Duration(seconds: 2)),
+            rated: true,
+          ),
+        ),
+        overrides: [
+          lichessClientProvider
+              .overrideWith((ref) => LichessClient(client, ref)),
+          webSocketChannelFactoryProvider.overrideWith((ref) {
+            return FakeWebSocketChannelFactory(
+              (String url) =>
+                  url.contains('lobby') ? fakeLobbySocket : fakeGameSocket,
+            );
+          }),
+        ],
+      );
+      await tester.pumpWidget(app);
+
+      expect(find.byType(Chessboard), findsOneWidget);
+      expect(find.byType(PieceWidget), findsNothing);
+      expect(find.text('Waiting for opponent to join...'), findsOneWidget);
+      expect(find.text('3+2'), findsOneWidget);
+      expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsOneWidget);
+
+      // waiting for the game
+      await tester.pump(const Duration(seconds: 2));
+
+      // when a seek is accepted, server sends a 'redirect' message with game id
+      fakeLobbySocket.addIncomingMessages([
+        '{"t": "redirect", "d": {"id": "qVChCOTcHSeW" }, "v": 1}',
+      ]);
+      await tester.pump(const Duration(milliseconds: 1));
+
+      // now the game controller is loading
+      expect(find.byType(Chessboard), findsOneWidget);
+      expect(find.byType(PieceWidget), findsNothing);
+      expect(find.text('Waiting for opponent to join...'), findsNothing);
+      expect(find.text('3+2'), findsNothing);
+      expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsNothing);
+
+      await fakeGameSocket.connectionEstablished;
+      // now that game socket is open, lobby socket should be closed
+      expect(fakeLobbySocket.closeCode, isNotNull);
+
+      fakeGameSocket.addIncomingMessages([
+        makeFullEvent(
+          const GameId('qVChCOTc'),
+          '',
+          whiteUserName: 'Peter',
+          blackUserName: 'Steven',
+        ),
+      ]);
+      // wait for socket message
+      await tester.pump(const Duration(milliseconds: 10));
+
+      expect(find.byType(PieceWidget), findsNWidgets(32));
+      expect(find.text('Peter'), findsOneWidget);
+      expect(find.text('Steven'), findsOneWidget);
+      expect(find.text('Waiting for opponent to join...'), findsNothing);
+      expect(find.text('3+2'), findsNothing);
+    });
+  });
+
+  group('Clock', () {
+    testWidgets('loads on game start', (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+      await createTestGame(fakeSocket, tester);
+      expect(findClockWithTime('3:00'), findsNWidgets(2));
+      expect(
+        tester
+            .widgetList<Clock>(find.byType(Clock))
+            .where((widget) => widget.active == false)
+            .length,
+        2,
+        reason: 'clocks are not active yet',
+      );
+    });
+
+    testWidgets('ticks after the first full move', (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+      await createTestGame(fakeSocket, tester);
+      expect(findClockWithTime('3:00'), findsNWidgets(2));
+      await playMove(tester, 'e2', 'e4');
+      // at that point clock is not yet started
+      expect(
+        tester
+            .widgetList<Clock>(find.byType(Clock))
+            .where((widget) => widget.active == false)
+            .length,
+        2,
+        reason: 'clocks are not active yet',
+      );
+      fakeSocket.addIncomingMessages([
+        '{"t": "move", "v": 1, "d": {"ply": 1, "uci": "e2e4", "san": "e4", "clock": {"white": 180, "black": 180}}}',
+        '{"t": "move", "v": 2, "d": {"ply": 2, "uci": "e7e5", "san": "e5", "clock": {"white": 180, "black": 180}}}',
+      ]);
+      await tester.pump(const Duration(milliseconds: 10));
+      expect(
+        tester.widgetList<Clock>(find.byType(Clock)).last.active,
+        true,
+        reason: 'my clock is now active',
+      );
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:59'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:58'), findsOneWidget);
+    });
+
+    testWidgets('ticks immediately when resuming game',
+        (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+      await createTestGame(
+        fakeSocket,
+        tester,
+        pgn: 'e4 e5 Nf3',
+        clock: const (
+          running: true,
+          initial: Duration(minutes: 3),
+          increment: Duration(seconds: 2),
+          white: Duration(minutes: 2, seconds: 58),
+          black: Duration(minutes: 2, seconds: 54),
+          emerg: Duration(seconds: 30),
+        ),
+      );
+      expect(
+        tester.widgetList<Clock>(find.byType(Clock)).first.active,
+        true,
+        reason: 'black clock is already active',
+      );
+      expect(findClockWithTime('2:58'), findsOneWidget);
+      expect(findClockWithTime('2:54'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:53'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:52'), findsOneWidget);
+    });
+
+    testWidgets('switch timer side after a move', (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+      await createTestGame(
+        fakeSocket,
+        tester,
+        pgn: 'e4 e5',
+        clock: const (
+          running: true,
+          initial: Duration(minutes: 3),
+          increment: Duration(seconds: 2),
+          white: Duration(minutes: 2, seconds: 58),
+          black: Duration(minutes: 3),
+          emerg: Duration(seconds: 30),
+        ),
+      );
+      expect(tester.widgetList<Clock>(find.byType(Clock)).last.active, true);
+      // simulates think time of 3s
+      await tester.pump(const Duration(seconds: 3));
+      await playMove(tester, 'g1', 'f3');
+      expect(findClockWithTime('2:55'), findsOneWidget);
+      expect(
+        tester.widgetList<Clock>(find.byType(Clock)).last.active,
+        false,
+        reason: 'white clock is stopped while waiting for server ack',
+      );
+      expect(
+        tester.widgetList<Clock>(find.byType(Clock)).first.active,
+        true,
+        reason: 'black clock is now active but not yet ticking',
+      );
+      expect(findClockWithTime('3:00'), findsOneWidget);
+      // simulates a long lag just to show the clock is not running yet
+      await tester.pump(const Duration(milliseconds: 200));
+      expect(findClockWithTime('3:00'), findsOneWidget);
+      // server ack having the white clock updated with the increment
+      fakeSocket.addIncomingMessages([
+        '{"t": "move", "v": 1, "d": {"ply": 3, "uci": "g1f3", "san": "Nf3", "clock": {"white": 177, "black": 180}}}',
+      ]);
+      await tester.pump(const Duration(milliseconds: 10));
+      // we see now the white clock has got its increment
+      expect(findClockWithTime('2:57'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      // black clock is ticking
+      expect(findClockWithTime('2:59'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:57'), findsOneWidget);
+      expect(findClockWithTime('2:58'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:57'), findsNWidgets(2));
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:57'), findsOneWidget);
+      expect(findClockWithTime('2:56'), findsOneWidget);
+    });
+
+    testWidgets('compensates opponent lag', (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+      int socketVersion = 0;
+      await createTestGame(
+        fakeSocket,
+        tester,
+        pgn: 'e4 e5 Nf3 Nc6',
+        clock: const (
+          running: true,
+          initial: Duration(minutes: 1),
+          increment: Duration.zero,
+          white: Duration(seconds: 58),
+          black: Duration(seconds: 54),
+          emerg: Duration(seconds: 10),
+        ),
+        socketVersion: socketVersion,
+      );
+      await tester.pump(const Duration(seconds: 3));
+      await playMoveWithServerAck(
+        fakeSocket,
+        tester,
+        'f1',
+        'c4',
+        ply: 5,
+        san: 'Bc4',
+        clockAck: (
+          white: const Duration(seconds: 55),
+          black: const Duration(seconds: 54),
+          lag: const Duration(milliseconds: 250),
+        ),
+        socketVersion: ++socketVersion,
+      );
+      // black clock is active
+      expect(tester.widgetList<Clock>(find.byType(Clock)).first.active, true);
+      expect(findClockWithTime('0:54'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 250));
+      // lag is 250ms, so clock will only start after that delay
+      expect(findClockWithTime('0:54'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(findClockWithTime('0:53'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('0:52'), findsOneWidget);
+    });
+
+    testWidgets('onEmergency', (WidgetTester tester) async {
+      final mockSoundService = MockSoundService();
+      when(() => mockSoundService.play(Sound.lowTime)).thenAnswer((_) async {});
+      final fakeSocket = FakeWebSocketChannel();
+      await createTestGame(
+        fakeSocket,
+        tester,
+        pgn: 'e4 e5',
+        clock: const (
+          running: true,
+          initial: Duration(minutes: 3),
+          increment: Duration(seconds: 2),
+          white: Duration(seconds: 40),
+          black: Duration(minutes: 3),
+          emerg: Duration(seconds: 30),
+        ),
+        overrides: [
+          soundServiceProvider.overrideWith((_) => mockSoundService),
+        ],
+      );
+      expect(
+        tester.widget<Clock>(findClockWithTime('0:40')).emergencyThreshold,
+        const Duration(seconds: 30),
+      );
+      await tester.pump(const Duration(seconds: 10));
+      expect(findClockWithTime('0:30'), findsOneWidget);
+      verify(() => mockSoundService.play(Sound.lowTime)).called(1);
+    });
+
+    testWidgets('flags', (WidgetTester tester) async {
+      final fakeSocket = FakeWebSocketChannel();
+      await createTestGame(
+        fakeSocket,
+        tester,
+        pgn: 'e4 e5 Nf3',
+        clock: const (
+          running: true,
+          initial: Duration(minutes: 3),
+          increment: Duration(seconds: 2),
+          white: Duration(minutes: 2, seconds: 58),
+          black: Duration(minutes: 2, seconds: 54),
+          emerg: Duration(seconds: 30),
+        ),
+      );
+      expect(
+        tester.widgetList<Clock>(find.byType(Clock)).first.active,
+        true,
+        reason: 'black clock is active',
+      );
+
+      expect(findClockWithTime('2:58'), findsOneWidget);
+      expect(findClockWithTime('2:54'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 1));
+      expect(findClockWithTime('2:53'), findsOneWidget);
+      await tester.pump(const Duration(minutes: 2, seconds: 53));
+      expect(findClockWithTime('2:58'), findsOneWidget);
+      expect(findClockWithTime('0:00.0'), findsOneWidget);
+
+      expect(
+        tester.widgetList<Clock>(find.byType(Clock)).first.active,
+        true,
+        reason:
+            'black clock is still active after flag (as long as we have not received server ack)',
+      );
+
+      // flag messages are throttled with 500ms delay
+      // we'll simulate an anormally long server response of 1s to check 2
+      // flag messages are sent
+      expectLater(
+        fakeSocket.sentMessagesExceptPing,
+        emitsInOrder([
+          '{"t":"flag","d":"black"}',
+          '{"t":"flag","d":"black"}',
+        ]),
+      );
+      await tester.pump(const Duration(seconds: 1));
+      fakeSocket.addIncomingMessages([
+        '{"t":"endData","d":{"status":"outoftime","winner":"white","clock":{"wc":17800,"bc":0}}}',
+      ]);
+      await tester.pump(const Duration(milliseconds: 10));
+
+      expect(
+        tester
+            .widgetList<Clock>(find.byType(Clock))
+            .where((widget) => widget.active == false)
+            .length,
+        2,
+        reason: 'both clocks are now inactive',
+      );
+      expect(findClockWithTime('2:58'), findsOneWidget);
+      expect(findClockWithTime('0:00.00'), findsOneWidget);
+
+      // wait for the dong
+      await tester.pump(const Duration(seconds: 500));
+    });
+  });
+}
+
+Finder findClockWithTime(String text, {bool skipOffstage = true}) {
+  return find.ancestor(
+    of: find.text(text, findRichText: true, skipOffstage: skipOffstage),
+    matching: find.byType(Clock, skipOffstage: skipOffstage),
+  );
+}
+
+/// Simulates playing a move and getting the ack from the server after [elapsedTime].
+Future<void> playMoveWithServerAck(
+  FakeWebSocketChannel socket,
+  WidgetTester tester,
+  String from,
+  String to, {
+  required String san,
+  required ({Duration white, Duration black, Duration? lag}) clockAck,
+  required int socketVersion,
+  required int ply,
+  Duration elapsedTime = const Duration(milliseconds: 10),
+  Side orientation = Side.white,
+}) async {
+  await playMove(tester, from, to, orientation: orientation);
+  final uci = '$from$to';
+  final lagStr = clockAck.lag != null
+      ? ', "lag": ${(clockAck.lag!.inMilliseconds / 10).round()}'
+      : '';
+  await tester.pump(elapsedTime - const Duration(milliseconds: 1));
+  socket.addIncomingMessages([
+    '{"t": "move", "v": $socketVersion, "d": {"ply": $ply, "uci": "$uci", "san": "$san", "clock": {"white": ${(clockAck.white.inMilliseconds / 1000).toStringAsFixed(2)}, "black": ${(clockAck.black.inMilliseconds / 1000).toStringAsFixed(2)}$lagStr}}}',
+  ]);
+  await tester.pump(const Duration(milliseconds: 1));
+}
+
+/// Convenient function to start a new test game
+Future<void> createTestGame(
+  FakeWebSocketChannel socket,
+  WidgetTester tester, {
+  Side? youAre = Side.white,
+  String? pgn,
+  int socketVersion = 0,
+  FullEventTestClock clock = const (
+    running: false,
+    initial: Duration(minutes: 3),
+    increment: Duration(seconds: 2),
+    white: Duration(minutes: 3),
+    black: Duration(minutes: 3),
+    emerg: Duration(seconds: 30),
+  ),
+  List<Override>? overrides,
+}) async {
+  final app = await makeTestProviderScopeApp(
+    tester,
+    home: const GameScreen(
+      initialGameId: GameFullId('qVChCOTcHSeW'),
+    ),
+    overrides: [
+      lichessClientProvider.overrideWith((ref) => LichessClient(client, ref)),
+      webSocketChannelFactoryProvider.overrideWith((ref) {
+        return FakeWebSocketChannelFactory((_) => socket);
+      }),
+      ...?overrides,
+    ],
+  );
+  await tester.pumpWidget(app);
+  await tester.pump(const Duration(milliseconds: 10));
+  await socket.connectionEstablished;
+
+  socket.addIncomingMessages([
+    makeFullEvent(
+      const GameId('qVChCOTc'),
+      pgn ?? '',
+      whiteUserName: 'Peter',
+      blackUserName: 'Steven',
+      youAre: youAre,
+      socketVersion: socketVersion,
+      clock: clock,
+    ),
+  ]);
+  await tester.pump(const Duration(milliseconds: 10));
+}
diff --git a/test/view/over_the_board/over_the_board_screen_test.dart b/test/view/over_the_board/over_the_board_screen_test.dart
index febe1c1614..720ccfba09 100644
--- a/test/view/over_the_board/over_the_board_screen_test.dart
+++ b/test/view/over_the_board/over_the_board_screen_test.dart
@@ -9,7 +9,7 @@ import 'package:lichess_mobile/src/model/common/time_increment.dart';
 import 'package:lichess_mobile/src/model/over_the_board/over_the_board_clock.dart';
 import 'package:lichess_mobile/src/model/over_the_board/over_the_board_game_controller.dart';
 import 'package:lichess_mobile/src/view/over_the_board/over_the_board_screen.dart';
-import 'package:lichess_mobile/src/widgets/countdown_clock.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
 
 import '../../test_helpers.dart';
 import '../../test_provider_scope.dart';
@@ -28,11 +28,11 @@ void main() {
         boardRect.bottomLeft,
       );
 
-      await playMove(tester, boardRect, 'e2', 'e4');
-      await playMove(tester, boardRect, 'f7', 'f6');
-      await playMove(tester, boardRect, 'd2', 'd4');
-      await playMove(tester, boardRect, 'g7', 'g5');
-      await playMove(tester, boardRect, 'd1', 'h5');
+      await playMove(tester, 'e2', 'e4');
+      await playMove(tester, 'f7', 'f6');
+      await playMove(tester, 'd2', 'd4');
+      await playMove(tester, 'g7', 'g5');
+      await playMove(tester, 'd1', 'h5');
 
       await tester.pumpAndSettle(const Duration(milliseconds: 600));
       expect(find.text('Checkmate • White is victorious'), findsOneWidget);
@@ -58,13 +58,13 @@ void main() {
 
     testWidgets('Game ends when out of time', (tester) async {
       const time = Duration(seconds: 1);
-      final boardRect = await initOverTheBoardGame(
+      await initOverTheBoardGame(
         tester,
         TimeIncrement(time.inSeconds, 0),
       );
 
-      await playMove(tester, boardRect, 'e2', 'e4');
-      await playMove(tester, boardRect, 'e7', 'e5');
+      await playMove(tester, 'e2', 'e4');
+      await playMove(tester, 'e7', 'e5');
 
       // The clock measures system time internally, so we need to actually sleep in order
       // for the clock to reach 0, instead of using tester.pump()
@@ -81,13 +81,13 @@ void main() {
     testWidgets('Pausing the clock', (tester) async {
       const time = Duration(seconds: 10);
 
-      final boardRect = await initOverTheBoardGame(
+      await initOverTheBoardGame(
         tester,
         TimeIncrement(time.inSeconds, 0),
       );
 
-      await playMove(tester, boardRect, 'e2', 'e4');
-      await playMove(tester, boardRect, 'e7', 'e5');
+      await playMove(tester, 'e2', 'e4');
+      await playMove(tester, 'e7', 'e5');
 
       await tester.tap(find.byTooltip('Pause'));
       await tester.pump();
@@ -108,7 +108,7 @@ void main() {
       expect(activeClock(tester), null);
 
       // ... but playing a move resumes the clock
-      await playMove(tester, boardRect, 'd7', 'd5');
+      await playMove(tester, 'd7', 'd5');
 
       expect(activeClock(tester), Side.white);
     });
@@ -116,13 +116,13 @@ void main() {
     testWidgets('Go back and Forward', (tester) async {
       const time = Duration(seconds: 10);
 
-      final boardRect = await initOverTheBoardGame(
+      await initOverTheBoardGame(
         tester,
         TimeIncrement(time.inSeconds, 0),
       );
 
-      await playMove(tester, boardRect, 'e2', 'e4');
-      await playMove(tester, boardRect, 'e7', 'e5');
+      await playMove(tester, 'e2', 'e4');
+      await playMove(tester, 'e7', 'e5');
 
       await tester.tap(find.byTooltip('Previous'));
       await tester.pumpAndSettle();
@@ -148,7 +148,7 @@ void main() {
 
       expect(activeClock(tester), Side.white);
 
-      await playMove(tester, boardRect, 'e2', 'e4');
+      await playMove(tester, 'e2', 'e4');
       expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
 
       expect(activeClock(tester), Side.black);
@@ -166,7 +166,7 @@ void main() {
     testWidgets('Clock logic', (tester) async {
       const time = Duration(minutes: 5);
 
-      final boardRect = await initOverTheBoardGame(
+      await initOverTheBoardGame(
         tester,
         TimeIncrement(time.inSeconds, 3),
       );
@@ -176,7 +176,7 @@ void main() {
       expect(findWhiteClock(tester).timeLeft, time);
       expect(findBlackClock(tester).timeLeft, time);
 
-      await playMove(tester, boardRect, 'e2', 'e4');
+      await playMove(tester, 'e2', 'e4');
 
       const moveTime = Duration(milliseconds: 500);
       await tester.pumpAndSettle(moveTime);
@@ -186,7 +186,7 @@ void main() {
       expect(findWhiteClock(tester).timeLeft, time);
       expect(findBlackClock(tester).timeLeft, lessThan(time));
 
-      await playMove(tester, boardRect, 'e7', 'e5');
+      await playMove(tester, 'e7', 'e5');
       await tester.pumpAndSettle();
 
       expect(activeClock(tester), Side.white);
diff --git a/test/view/puzzle/puzzle_screen_test.dart b/test/view/puzzle/puzzle_screen_test.dart
index 41497a07ab..8845f25da3 100644
--- a/test/view/puzzle/puzzle_screen_test.dart
+++ b/test/view/puzzle/puzzle_screen_test.dart
@@ -209,9 +209,7 @@ void main() {
         expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
         expect(find.byKey(const Key('h8-whitequeen')), findsOneWidget);
 
-        final boardRect = tester.getRect(find.byType(Chessboard));
-
-        await playMove(tester, boardRect, 'g4', 'h4', orientation: orientation);
+        await playMove(tester, 'g4', 'h4', orientation: orientation);
 
         expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
         expect(find.text('Best move!'), findsOneWidget);
@@ -222,7 +220,7 @@ void main() {
 
         expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget);
 
-        await playMove(tester, boardRect, 'b4', 'h4', orientation: orientation);
+        await playMove(tester, 'b4', 'h4', orientation: orientation);
 
         expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
         expect(find.text('Success!'), findsOneWidget);
@@ -313,9 +311,7 @@ void main() {
 
         expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
 
-        final boardRect = tester.getRect(find.byType(Chessboard));
-
-        await playMove(tester, boardRect, 'g4', 'f4', orientation: orientation);
+        await playMove(tester, 'g4', 'f4', orientation: orientation);
 
         expect(
           find.text("That's not the move!"),
@@ -329,7 +325,7 @@ void main() {
         // can still play the puzzle
         expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
 
-        await playMove(tester, boardRect, 'g4', 'h4', orientation: orientation);
+        await playMove(tester, 'g4', 'h4', orientation: orientation);
 
         expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
         expect(find.text('Best move!'), findsOneWidget);
@@ -338,7 +334,7 @@ void main() {
         await tester.pump(const Duration(milliseconds: 500));
         await tester.pumpAndSettle();
 
-        await playMove(tester, boardRect, 'b4', 'h4', orientation: orientation);
+        await playMove(tester, 'b4', 'h4', orientation: orientation);
 
         expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
         expect(
diff --git a/test/view/puzzle/storm_screen_test.dart b/test/view/puzzle/storm_screen_test.dart
index ac581af0d4..7f6e38996a 100644
--- a/test/view/puzzle/storm_screen_test.dart
+++ b/test/view/puzzle/storm_screen_test.dart
@@ -96,11 +96,8 @@ void main() {
 
         expect(find.byKey(const Key('g8-blackking')), findsOneWidget);
 
-        final boardRect = tester.getRect(find.byType(Chessboard));
-
         await playMove(
           tester,
-          boardRect,
           'h5',
           'h7',
           orientation: Side.white,
@@ -113,7 +110,6 @@ void main() {
 
         await playMove(
           tester,
-          boardRect,
           'e3',
           'g1',
           orientation: Side.white,
@@ -143,11 +139,8 @@ void main() {
       // wait for first move to be played
       await tester.pump(const Duration(seconds: 1));
 
-      final boardRect = tester.getRect(find.byType(Chessboard));
-
       await playMove(
         tester,
-        boardRect,
         'h5',
         'h7',
         orientation: Side.white,
@@ -156,7 +149,6 @@ void main() {
       await tester.pump(const Duration(milliseconds: 500));
       await playMove(
         tester,
-        boardRect,
         'e3',
         'g1',
         orientation: Side.white,
@@ -186,9 +178,8 @@ void main() {
       await tester.pumpWidget(app);
 
       await tester.pump(const Duration(seconds: 1));
-      final boardRect = tester.getRect(find.byType(Chessboard));
 
-      await playMove(tester, boardRect, 'h5', 'h6');
+      await playMove(tester, 'h5', 'h6');
 
       await tester.pump(const Duration(milliseconds: 500));
       expect(find.byKey(const Key('h6-blackking')), findsOneWidget);
diff --git a/test/view/study/study_screen_test.dart b/test/view/study/study_screen_test.dart
index dd80b3a92c..5073bfd240 100644
--- a/test/view/study/study_screen_test.dart
+++ b/test/view/study/study_screen_test.dart
@@ -1,4 +1,3 @@
-import 'package:chessground/chessground.dart';
 import 'package:dartchess/dartchess.dart';
 import 'package:fast_immutable_collections/fast_immutable_collections.dart';
 import 'package:flutter/widgets.dart';
@@ -225,13 +224,12 @@ void main() {
       // Wait for study to load
       await tester.pumpAndSettle();
 
-      final boardRect = tester.getRect(find.byType(Chessboard));
-      await playMove(tester, boardRect, 'e2', 'e4', orientation: Side.black);
+      await playMove(tester, 'e2', 'e4', orientation: Side.black);
 
       expect(find.byKey(const Key('e2-whitepawn')), findsNothing);
       expect(find.byKey(const Key('e4-whitepawn')), findsOneWidget);
 
-      await playMove(tester, boardRect, 'e7', 'e5', orientation: Side.black);
+      await playMove(tester, 'e7', 'e5', orientation: Side.black);
 
       expect(find.byKey(const Key('e5-blackpawn')), findsOneWidget);
       expect(find.byKey(const Key('e7-blackpawn')), findsNothing);
@@ -239,5 +237,232 @@ void main() {
       expect(find.text('1. e4'), findsOneWidget);
       expect(find.text('e5'), findsOneWidget);
     });
+
+    testWidgets('Interactive study', (WidgetTester tester) async {
+      final mockRepository = MockStudyRepository();
+      when(() => mockRepository.getStudy(id: testId)).thenAnswer(
+        (_) async => (
+          makeStudy(
+            chapter: makeChapter(
+              id: const StudyChapterId('1'),
+              orientation: Side.white,
+              gamebook: true,
+            ),
+          ),
+          '''
+[Event "Improve Your Chess Calculation: Candidates| Ex 1: Hard"]
+[Site "https://lichess.org/study/xgZOEizT/OfF4eLmN"]
+[Result "*"]
+[Variant "Standard"]
+[ECO "?"]
+[Opening "?"]
+[Annotator "https://lichess.org/@/RushConnectedPawns"]
+[FEN "r1b2rk1/3pbppp/p3p3/1p6/2qBPP2/P1N2R2/1PPQ2PP/R6K w - - 0 1"]
+[SetUp "1"]
+[UTCDate "2024.10.23"]
+[UTCTime "02:04:11"]
+[ChapterMode "gamebook"]
+
+{ We begin our lecture with an 'easy but not easy' example. White to play and win. }
+1. Nd5!! { Brilliant! You noticed that the queen on c4 was kinda smothered. } (1. Ne2? { Not much to say after ...Qc7. }) 1... exd5 2. Rc3 Qa4 3. Rg3! { A fork, threatening Rg7 & b3. } { [%csl Gg7][%cal Gg3g7,Gd4g7,Gb2b3] } (3. Rxc8?? { Uh-oh! After Rc8, b3, there is the counter-sac Rxc2, which is winning for black!! } 3... Raxc8 4. b3 Rxc2!! 5. Qxc2 Qxd4 \$19) 3... g6 4. b3 \$18 { ...and the queen is trapped. GGs. If this was too hard for you, don't worry, there will be easier examples. } *
+          '''
+        ),
+      );
+
+      final app = await makeTestProviderScopeApp(
+        tester,
+        home: const StudyScreen(id: testId),
+        overrides: [
+          studyRepositoryProvider.overrideWith(
+            (ref) => mockRepository,
+          ),
+        ],
+      );
+      await tester.pumpWidget(app);
+      // Wait for study to load
+      await tester.pumpAndSettle();
+
+      const introText =
+          "We begin our lecture with an 'easy but not easy' example. White to play and win.";
+
+      expect(find.text(introText), findsOneWidget);
+
+      expect(
+        find.text(
+          'Brilliant! You noticed that the queen on c4 was kinda smothered.',
+        ),
+        findsNothing,
+      );
+
+      // Play a wrong move
+      await playMove(tester, 'c3', 'a2');
+      expect(find.text("That's not the move!"), findsOneWidget);
+      expect(find.text(introText), findsNothing);
+
+      // Wrong move will be taken back automatically after a short delay
+      await tester.pump(const Duration(seconds: 1));
+      expect(find.text("That's not move!"), findsNothing);
+      expect(find.text(introText), findsOneWidget);
+
+      // Play another wrong move, but this one has an explicit comment
+      await playMove(tester, 'c3', 'e2');
+
+      // If there's an explicit comment, the move is not taken back automatically
+      // Verify this by waiting the same duration as above
+      await tester.pump(const Duration(seconds: 1));
+
+      expect(find.text('Not much to say after ...Qc7.'), findsOneWidget);
+      expect(find.text(introText), findsNothing);
+
+      await tester.tap(find.byTooltip('Retry'));
+      await tester.pump(); // Wait for move to be taken back
+
+      expect(find.text(introText), findsOneWidget);
+
+      // Play the correct move
+      await playMove(tester, 'c3', 'd5');
+
+      expect(
+        find.text(
+          'Brilliant! You noticed that the queen on c4 was kinda smothered.',
+        ),
+        findsOneWidget,
+      );
+
+      // The move has an explicit feedback comment, so opponent move should not be played automatically
+      await tester.pump(const Duration(seconds: 1));
+
+      expect(
+        find.text(
+          'Brilliant! You noticed that the queen on c4 was kinda smothered.',
+        ),
+        findsOneWidget,
+      );
+
+      await tester.tap(find.byTooltip('Next'));
+      await tester.pump(); // Wait for opponent move to be played
+
+      expect(
+        find.text('What would you play in this position?'),
+        findsOneWidget,
+      );
+
+      await playMove(tester, 'f3', 'c3');
+      expect(find.text('Good move'), findsOneWidget);
+
+      // No explicit feedback, so opponent move should be played automatically after delay
+      await tester.pump(const Duration(seconds: 1));
+
+      expect(
+        find.text('What would you play in this position?'),
+        findsOneWidget,
+      );
+
+      await playMove(tester, 'c3', 'g3');
+      expect(find.text('A fork, threatening Rg7 & b3.'), findsOneWidget);
+
+      await tester.tap(find.byTooltip('Next'));
+      await tester.pump(); // Wait for opponent move to be played
+
+      expect(
+        find.text('What would you play in this position?'),
+        findsOneWidget,
+      );
+
+      await playMove(tester, 'b2', 'b3');
+
+      expect(
+        find.text(
+          "...and the queen is trapped. GGs. If this was too hard for you, don't worry, there will be easier examples.",
+        ),
+        findsOneWidget,
+      );
+
+      expect(find.byTooltip('Play again'), findsOneWidget);
+      expect(find.byTooltip('Next chapter'), findsOneWidget);
+      expect(find.byTooltip('Analysis board'), findsOneWidget);
+    });
+
+    testWidgets('Interactive study hints and deviation comments',
+        (WidgetTester tester) async {
+      final mockRepository = MockStudyRepository();
+      when(() => mockRepository.getStudy(id: testId)).thenAnswer(
+        (_) async => (
+          makeStudy(
+            chapter: makeChapter(
+              id: const StudyChapterId('1'),
+              orientation: Side.white,
+              gamebook: true,
+            ),
+            hints: [
+              'Hint 1',
+              null,
+              null,
+              null,
+            ].lock,
+            deviationComments: [
+              null,
+              'Shown if any move other than d4 is played',
+              null,
+              null,
+            ].lock,
+          ),
+          '1. e4 (1. d4 {Shown if d4 is played}) e5 2. Nf3'
+        ),
+      );
+
+      final app = await makeTestProviderScopeApp(
+        tester,
+        home: const StudyScreen(id: testId),
+        overrides: [
+          studyRepositoryProvider.overrideWith(
+            (ref) => mockRepository,
+          ),
+        ],
+      );
+      await tester.pumpWidget(app);
+      // Wait for study to load
+      await tester.pumpAndSettle();
+
+      expect(find.text('Get a hint'), findsOneWidget);
+      expect(find.text('Hint 1'), findsNothing);
+
+      await tester.tap(find.text('Get a hint'));
+      await tester.pump(); // Wait for hint to be shown
+      expect(find.text('Hint 1'), findsOneWidget);
+      expect(find.text('Get a hint'), findsNothing);
+
+      await playMove(tester, 'e2', 'e3');
+      expect(
+        find.text('Shown if any move other than d4 is played'),
+        findsOneWidget,
+      );
+      await tester.tap(find.byTooltip('Retry'));
+      await tester.pump(); // Wait for move to be taken back
+
+      await playMove(tester, 'd2', 'd4');
+      expect(find.text('Shown if d4 is played'), findsOneWidget);
+      await tester.tap(find.byTooltip('Retry'));
+      await tester.pump(); // Wait for move to be taken back
+
+      expect(find.text('View the solution'), findsOneWidget);
+      await tester.tap(find.byTooltip('View the solution'));
+      // Wait for correct move and opponent's response to be played
+      await tester.pump(const Duration(seconds: 1));
+
+      expect(find.text('Get a hint'), findsNothing);
+
+      // Play a wrong move again - generic feedback should be shown
+      await playMove(tester, 'a2', 'a3');
+      expect(find.text("That's not the move!"), findsOneWidget);
+      // Wait for wrong move to be taken back
+      await tester.pump(const Duration(seconds: 1));
+
+      expect(
+        find.text('What would you play in this position?'),
+        findsOneWidget,
+      );
+      expect(find.text("That's not the move!"), findsNothing);
+    });
   });
 }
diff --git a/test/view/user/perf_stats_screen_test.dart b/test/view/user/perf_stats_screen_test.dart
index a2a208f8e4..33013947fd 100644
--- a/test/view/user/perf_stats_screen_test.dart
+++ b/test/view/user/perf_stats_screen_test.dart
@@ -90,7 +90,7 @@ void main() {
         ];
 
         // rating
-        expect(find.text('1500.42'), findsOneWidget);
+        expect(find.text('1500'), findsOneWidget);
 
         for (final val in requiredStatsValues) {
           expect(
diff --git a/test/widgets/clock_test.dart b/test/widgets/clock_test.dart
new file mode 100644
index 0000000000..73e20078f6
--- /dev/null
+++ b/test/widgets/clock_test.dart
@@ -0,0 +1,249 @@
+import 'package:clock/clock.dart' as clock;
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lichess_mobile/src/widgets/clock.dart';
+
+void main() {
+  group('Clock', () {
+    testWidgets('shows milliseconds when time < 1s and active is false',
+        (WidgetTester tester) async {
+      await tester.pumpWidget(
+        const MaterialApp(
+          home: Clock(
+            timeLeft: Duration(seconds: 1),
+            active: true,
+          ),
+        ),
+      );
+
+      expect(find.text('0:01.0', findRichText: true), findsOneWidget);
+
+      await tester.pumpWidget(
+        const MaterialApp(
+          home: Clock(
+            timeLeft: Duration(milliseconds: 988),
+            active: false,
+          ),
+        ),
+        duration: const Duration(milliseconds: 1000),
+      );
+
+      expect(find.text('0:00.98', findRichText: true), findsOneWidget);
+    });
+  });
+
+  group('CountdownClockBuilder', () {
+    Widget clockBuilder(BuildContext context, Duration timeLeft) {
+      final mins = timeLeft.inMinutes.remainder(60);
+      final secs = timeLeft.inSeconds.remainder(60).toString().padLeft(2, '0');
+      final tenths = timeLeft.inMilliseconds.remainder(1000) ~/ 100;
+      return Text('$mins:$secs.$tenths');
+    }
+
+    testWidgets('does not tick when not active', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: false,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+
+      expect(find.text('0:10.0'), findsOneWidget);
+
+      await tester.pump(const Duration(seconds: 2));
+      expect(find.text('0:10.0'), findsOneWidget);
+    });
+
+    testWidgets('ticks when active', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: true,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+
+      expect(find.text('0:10.0'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.9'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.8'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 10));
+      expect(find.text('0:00.0'), findsOneWidget);
+    });
+
+    testWidgets('update time by changing widget configuration',
+        (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            clockUpdatedAt: clock.clock.now(),
+            active: true,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+
+      expect(find.text('0:10.0'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.9'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.8'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.7'), findsOneWidget);
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 11),
+            clockUpdatedAt: clock.clock.now(),
+            active: true,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+      expect(find.text('0:11.0'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:10.9'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 11));
+      expect(find.text('0:00.0'), findsOneWidget);
+    });
+
+    testWidgets('do not update if clockUpdatedAt is same',
+        (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: true,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+
+      expect(find.text('0:10.0'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.9'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.8'), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.7'), findsOneWidget);
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 11),
+            active: true,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+
+      expect(find.text('0:09.7'), findsOneWidget);
+      await tester.pump(const Duration(seconds: 10));
+      expect(find.text('0:00.0'), findsOneWidget);
+    });
+
+    testWidgets('stops when active become false', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: true,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+
+      expect(find.text('0:10.0', findRichText: true), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.9', findRichText: true), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.8', findRichText: true), findsOneWidget);
+
+      // clock is rebuilt with same time but inactive:
+      // the time is kept and the clock stops counting the elapsed time
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: false,
+            builder: clockBuilder,
+          ),
+        ),
+        duration: const Duration(milliseconds: 100),
+      );
+      expect(find.text('0:09.7', findRichText: true), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.7', findRichText: true), findsOneWidget);
+    });
+
+    testWidgets('starts with a delay if set', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: true,
+            delay: const Duration(milliseconds: 250),
+            builder: clockBuilder,
+          ),
+        ),
+      );
+      expect(find.text('0:10.0', findRichText: true), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 250));
+      expect(find.text('0:10.0', findRichText: true), findsOneWidget);
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.9', findRichText: true), findsOneWidget);
+    });
+
+    testWidgets('compensates for UI lag', (WidgetTester tester) async {
+      final now = clock.clock.now();
+      await tester.pump(const Duration(milliseconds: 100));
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: true,
+            delay: const Duration(milliseconds: 200),
+            clockUpdatedAt: now,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+      expect(find.text('0:10.0', findRichText: true), findsOneWidget);
+
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:10.0', findRichText: true), findsOneWidget);
+
+      // delay was 200ms but UI lagged 100ms so with the compensation the clock has started already
+      await tester.pump(const Duration(milliseconds: 100));
+      expect(find.text('0:09.9', findRichText: true), findsOneWidget);
+    });
+
+    testWidgets('UI lag negative start delay', (WidgetTester tester) async {
+      final now = clock.clock.now();
+      await tester.pump(const Duration(milliseconds: 200));
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: CountdownClockBuilder(
+            timeLeft: const Duration(seconds: 10),
+            active: true,
+            delay: const Duration(milliseconds: 100),
+            clockUpdatedAt: now,
+            builder: clockBuilder,
+          ),
+        ),
+      );
+      // delay was 100ms but UI lagged 200ms so the clock time is already 100ms ahead
+      expect(find.text('0:09.9', findRichText: true), findsOneWidget);
+    });
+  });
+}