diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 896149f238..e599912e2b 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -181,8 +181,8 @@ export interface ReversiGameEventTypes {
 		value: any;
 	};
 	log: Reversi.Serializer.Log & { id: string | null };
-	syncState: {
-		crc32: string;
+	heatbeat: {
+		userId: MiUser['id'];
 	};
 	started: {
 		game: Packed<'ReversiGameDetailed'>;
diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts
index 9fe7255e48..e626cbaf19 100644
--- a/packages/backend/src/core/ReversiService.ts
+++ b/packages/backend/src/core/ReversiService.ts
@@ -405,6 +405,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
 		return this.reversiGamesRepository.findOneBy({ id });
 	}
 
+	@bindThis
+	public async heatbeat(game: MiReversiGame, user: MiUser) {
+		this.globalEventService.publishReversiGameStream(game.id, 'heatbeat', { userId: user.id });
+	}
+
 	@bindThis
 	public dispose(): void {
 	}
diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts
index 2d8c396db9..c5d05e5cfb 100644
--- a/packages/backend/src/server/api/stream/channels/reversi-game.ts
+++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts
@@ -46,7 +46,7 @@ class ReversiGameChannel extends Channel {
 			case 'ready': this.ready(body); break;
 			case 'updateSettings': this.updateSettings(body.key, body.value); break;
 			case 'putStone': this.putStone(body.pos, body.id); break;
-			case 'syncState': this.syncState(body.crc32); break;
+			case 'heatbeat': this.heatbeat(body.crc32); break;
 		}
 	}
 
@@ -83,15 +83,21 @@ class ReversiGameChannel extends Channel {
 	}
 
 	@bindThis
-	private async syncState(crc32: string | number) {
+	private async heatbeat(crc32?: string | number | null) {
 		// TODO: キャッシュしたい
 		const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
 		if (game == null) throw new Error('game not found');
 
 		if (!game.isStarted) return;
 
-		if (crc32.toString() !== game.crc32) {
-			this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user));
+		if (crc32 != null) {
+			if (crc32.toString() !== game.crc32) {
+				this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user));
+			}
+		}
+
+		if (this.user && (game.user1Id === this.user.id || game.user2Id === this.user.id)) {
+			this.reversiService.heatbeat(game, this.user);
 		}
 	}
 
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 3a24777db8..2f09cf39e8 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div v-if="(logPos !== game.logs.length) && turnUser" class="turn">
 				<Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.tsx._reversi.pastTurnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
 			</div>
-			<div v-if="iAmPlayer && !game.isEnded && !isMyTurn" class="turn1">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/></div>
+			<div v-if="iAmPlayer && !game.isEnded && !isMyTurn" class="turn1">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/><soan v-if="opponentNotResponding" style="margin-left: 8px;">({{ i18n.ts.notResponding }})</soan></div>
 			<div v-if="iAmPlayer && !game.isEnded && isMyTurn" class="turn2" style="animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</div>
 			<div v-if="game.isEnded && logPos == game.logs.length" class="result">
 				<template v-if="game.winner">
@@ -139,7 +139,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
+import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
 import * as CRC32 from 'crc-32';
 import * as Misskey from 'misskey-js';
 import * as Reversi from 'misskey-reversi';
@@ -239,7 +239,7 @@ if (game.value.isStarted && !game.value.isEnded) {
 		if (game.value.isEnded) return;
 		const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
 		if (_DEV_) console.log('crc32', crc32);
-		props.connection.send('syncState', {
+		props.connection.send('heatbeat', {
 			crc32: crc32,
 		});
 	}, 10000, { immediate: false, afterMounted: true });
@@ -339,6 +339,27 @@ function onStreamRescue(_game) {
 	checkEnd();
 }
 
+const opponentLastHeatbeatedAt = ref<number>(Date.now());
+const opponentNotResponding = ref<boolean>(false);
+
+useInterval(() => {
+	if (game.value.isEnded) return;
+	if (!iAmPlayer.value) return;
+
+	if (Date.now() - opponentLastHeatbeatedAt.value > 20000) {
+		opponentNotResponding.value = true;
+	} else {
+		opponentNotResponding.value = false;
+	}
+}, 1000, { immediate: false, afterMounted: true });
+
+function onStreamHeatbeat({ userId }) {
+	if ($i.id === userId) return;
+
+	opponentNotResponding.value = false;
+	opponentLastHeatbeatedAt.value = Date.now();
+}
+
 async function surrender() {
 	const { canceled } = await os.confirm({
 		type: 'warning',
@@ -390,12 +411,28 @@ function share() {
 
 onMounted(() => {
 	props.connection.on('log', onStreamLog);
+	props.connection.on('heatbeat', onStreamHeatbeat);
 	props.connection.on('rescue', onStreamRescue);
 	props.connection.on('ended', onStreamEnded);
 });
 
+onActivated(() => {
+	props.connection.on('log', onStreamLog);
+	props.connection.on('heatbeat', onStreamHeatbeat);
+	props.connection.on('rescue', onStreamRescue);
+	props.connection.on('ended', onStreamEnded);
+});
+
+onDeactivated(() => {
+	props.connection.off('log', onStreamLog);
+	props.connection.off('heatbeat', onStreamHeatbeat);
+	props.connection.off('rescue', onStreamRescue);
+	props.connection.off('ended', onStreamEnded);
+});
+
 onUnmounted(() => {
 	props.connection.off('log', onStreamLog);
+	props.connection.off('heatbeat', onStreamHeatbeat);
 	props.connection.off('rescue', onStreamRescue);
 	props.connection.off('ended', onStreamEnded);
 });
@@ -483,7 +520,7 @@ $gap: 4px;
 
 .boardCell {
 	background: transparent;
-	border-radius: 6px;
+	border-radius: 100%;
 	aspect-ratio: 1;
 	transform-style: preserve-3d;
 	perspective: 150px;
@@ -534,6 +571,6 @@ $gap: 4px;
 	display: block;
 	width: 100%;
 	height: 100%;
-	border-radius: 6px;
+	border-radius: 100%;
 }
 </style>
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
index 5fbbbef2c5..796e732208 100644
--- a/packages/frontend/src/pages/reversi/index.vue
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref } from 'vue';
+import { computed, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -214,6 +214,14 @@ onMounted(() => {
 	});
 });
 
+onDeactivated(() => {
+	cancelMatching();
+});
+
+onUnmounted(() => {
+	cancelMatching();
+});
+
 definePageMetadata(computed(() => ({
 	title: 'Reversi',
 	icon: 'ti ti-device-gamepad',
diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts
index b8c5431fb6..d8ffb2205b 100644
--- a/packages/frontend/src/scripts/use-interval.ts
+++ b/packages/frontend/src/scripts/use-interval.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { onMounted, onUnmounted } from 'vue';
+import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
 
 export function useInterval(fn: () => void, interval: number, options: {
 	immediate: boolean;
@@ -28,6 +28,16 @@ export function useInterval(fn: () => void, interval: number, options: {
 		intervalId = null;
 	};
 
+	onActivated(() => {
+		if (intervalId) return;
+		if (options.immediate) fn();
+		intervalId = window.setInterval(fn, interval);
+	});
+
+	onDeactivated(() => {
+		clear();
+	});
+
 	onUnmounted(() => {
 		clear();
 	});