diff --git a/locales/index.d.ts b/locales/index.d.ts
index a22cb6350716c1bebc8c5e79ce3b06fb270da7bc..85e0c6b244ceb9d04003f0916568e151d3e3b3c7 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -2633,6 +2633,41 @@ export interface Locale extends ILocale {
             "description": string;
         };
     };
+    "_reversi": {
+        "reversi": string;
+        "gameSettings": string;
+        "chooseBoard": string;
+        "blackOrWhite": string;
+        "blackIs": ParameterizedString<"name">;
+        "rules": string;
+        "thisGameIsStartedSoon": string;
+        "waitingForOther": string;
+        "waitingForMe": string;
+        "waitingBoth": string;
+        "ready": string;
+        "cancelReady": string;
+        "opponentTurn": string;
+        "myTurn": string;
+        "turnOf": ParameterizedString<"name">;
+        "pastTurnOf": ParameterizedString<"name">;
+        "surrender": string;
+        "surrendered": string;
+        "drawn": string;
+        "won": ParameterizedString<"name">;
+        "black": string;
+        "white": string;
+        "total": string;
+        "turnCount": ParameterizedString<"count">;
+        "myGames": string;
+        "allGames": string;
+        "ended": string;
+        "playing": string;
+        "isLlotheo": string;
+        "loopedMap": string;
+        "canPutEverywhere": string;
+        "freeMatch": string;
+        "lookingForPlayer": string;
+    };
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8749a5f49f0928e95fff1a62bef4efa8ff4759bb..6c8a453023c771a22b7bf80ab8e306d39b02841e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2506,3 +2506,38 @@ _dataSaver:
   _code:
     title: "コードハイライト"
     description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"
+
+_reversi:
+  reversi: "リバーシ"
+  gameSettings: "対局の設定"
+  chooseBoard: "ボードを選択"
+  blackOrWhite: "先行/後攻"
+  blackIs: "{name}が黒(先行)"
+  rules: "ルール"
+  thisGameIsStartedSoon: "対局はまもなく開始されます"
+  waitingForOther: "相手の準備が完了するのを待っています"
+  waitingForMe: "あなたの準備が完了するのを待っています"
+  waitingBoth: "準備してください"
+  ready: "準備完了"
+  cancelReady: "準備を再開"
+  opponentTurn: "相手のターンです"
+  myTurn: "あなたのターンです"
+  turnOf: "{name}のターンです"
+  pastTurnOf: "{name}のターン"
+  surrender: "投了"
+  surrendered: "投了により"
+  drawn: "引き分け"
+  won: "{name}の勝ち"
+  black: "é»’"
+  white: "白"
+  total: "合計"
+  turnCount: "{count}ターン目"
+  myGames: "自分の対局"
+  allGames: "みんなの対局"
+  ended: "終了"
+  playing: "対局中"
+  isLlotheo: "石の少ない方が勝ち(ロセオ)"
+  loopedMap: "ループマップ"
+  canPutEverywhere: "どこでも置けるモード"
+  freeMatch: "フリーマッチ"
+  lookingForPlayer: "対戦相手を探しています"
diff --git a/packages/backend/migration/1705475608437-reversi.js b/packages/backend/migration/1705475608437-reversi.js
new file mode 100644
index 0000000000000000000000000000000000000000..c9d69e2c7c656c900d2b7986d418f79e3245f010
--- /dev/null
+++ b/packages/backend/migration/1705475608437-reversi.js
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Reversi1705475608437 {
+    name = 'Reversi1705475608437'
+
+    async up(queryRunner) {
+        await queryRunner.query(`DROP INDEX "public"."IDX_b46ec40746efceac604142be1c"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_b604d92d6c7aec38627f6eaf16"`);
+        await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "createdAt"`);
+        await queryRunner.query(`ALTER TABLE "reversi_matching" DROP COLUMN "createdAt"`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "reversi_matching" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "reversi_game" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
+        await queryRunner.query(`CREATE INDEX "IDX_b604d92d6c7aec38627f6eaf16" ON "reversi_matching" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_b46ec40746efceac604142be1c" ON "reversi_game" ("createdAt") `);
+    }
+}
diff --git a/packages/backend/migration/1705654039457-reversi-2.js b/packages/backend/migration/1705654039457-reversi-2.js
new file mode 100644
index 0000000000000000000000000000000000000000..33747ba9f726ef9d4d2a257edfb411f78a6522bb
--- /dev/null
+++ b/packages/backend/migration/1705654039457-reversi-2.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Reversi21705654039457 {
+    name = 'Reversi21705654039457'
+
+    async up(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Accepted" TO "user1Ready"`);
+			await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Accepted" TO "user2Ready"`);
+    }
+
+    async down(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Ready" TO "user1Accepted"`);
+			await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Ready" TO "user2Accepted"`);
+    }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 5ab476295ccd6c8cb7e69b8d7b492f1ab90e0a53..f8e82c5a1c5c576ab68b51fb8e9c6b5145419e7e 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -107,6 +107,7 @@
 		"cli-highlight": "2.1.11",
 		"color-convert": "2.0.1",
 		"content-disposition": "0.5.4",
+		"crc-32": "^1.2.2",
 		"date-fns": "2.30.0",
 		"deep-email-validator": "0.1.21",
 		"fastify": "4.24.3",
@@ -133,6 +134,7 @@
 		"microformats-parser": "2.0.2",
 		"mime-types": "2.1.35",
 		"misskey-js": "workspace:*",
+		"misskey-reversi": "workspace:*",
 		"ms": "3.0.0-canary.1",
 		"nanoid": "5.0.4",
 		"nested-property": "4.0.0",
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index bc6d24b9513f9b9c63fb48139645df5a6a581613..c9e285346e2e0da51035079c75ab060ee8b6bd15 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -66,6 +66,8 @@ import { FeaturedService } from './FeaturedService.js';
 import { FanoutTimelineService } from './FanoutTimelineService.js';
 import { ChannelFollowingService } from './ChannelFollowingService.js';
 import { RegistryApiService } from './RegistryApiService.js';
+import { ReversiService } from './ReversiService.js';
+
 import { ChartLoggerService } from './chart/ChartLoggerService.js';
 import FederationChart from './chart/charts/federation.js';
 import NotesChart from './chart/charts/notes.js';
@@ -80,6 +82,7 @@ import PerUserFollowingChart from './chart/charts/per-user-following.js';
 import PerUserDriveChart from './chart/charts/per-user-drive.js';
 import ApRequestChart from './chart/charts/ap-request.js';
 import { ChartManagementService } from './chart/ChartManagementService.js';
+
 import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
 import { AntennaEntityService } from './entities/AntennaEntityService.js';
 import { AppEntityService } from './entities/AppEntityService.js';
@@ -112,6 +115,8 @@ import { UserListEntityService } from './entities/UserListEntityService.js';
 import { FlashEntityService } from './entities/FlashEntityService.js';
 import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
 import { RoleEntityService } from './entities/RoleEntityService.js';
+import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
+
 import { ApAudienceService } from './activitypub/ApAudienceService.js';
 import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
 import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@@ -199,6 +204,7 @@ const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', use
 const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
 const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
 const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
+const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
 
 const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
 const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -247,6 +253,7 @@ const $UserListEntityService: Provider = { provide: 'UserListEntityService', use
 const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService };
 const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
 const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
+const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
 
 const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
 const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@@ -336,6 +343,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		FanoutTimelineEndpointService,
 		ChannelFollowingService,
 		RegistryApiService,
+		ReversiService,
+
 		ChartLoggerService,
 		FederationChart,
 		NotesChart,
@@ -350,6 +359,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		PerUserDriveChart,
 		ApRequestChart,
 		ChartManagementService,
+
 		AbuseUserReportEntityService,
 		AntennaEntityService,
 		AppEntityService,
@@ -382,6 +392,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		FlashEntityService,
 		FlashLikeEntityService,
 		RoleEntityService,
+		ReversiGameEntityService,
+
 		ApAudienceService,
 		ApDbResolverService,
 		ApDeliverManagerService,
@@ -466,6 +478,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$FanoutTimelineEndpointService,
 		$ChannelFollowingService,
 		$RegistryApiService,
+		$ReversiService,
+
 		$ChartLoggerService,
 		$FederationChart,
 		$NotesChart,
@@ -480,6 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$PerUserDriveChart,
 		$ApRequestChart,
 		$ChartManagementService,
+
 		$AbuseUserReportEntityService,
 		$AntennaEntityService,
 		$AppEntityService,
@@ -512,6 +527,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$FlashEntityService,
 		$FlashLikeEntityService,
 		$RoleEntityService,
+		$ReversiGameEntityService,
+
 		$ApAudienceService,
 		$ApDbResolverService,
 		$ApDeliverManagerService,
@@ -597,6 +614,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		FanoutTimelineEndpointService,
 		ChannelFollowingService,
 		RegistryApiService,
+		ReversiService,
+
 		FederationChart,
 		NotesChart,
 		UsersChart,
@@ -610,6 +629,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		PerUserDriveChart,
 		ApRequestChart,
 		ChartManagementService,
+
 		AbuseUserReportEntityService,
 		AntennaEntityService,
 		AppEntityService,
@@ -642,6 +662,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		FlashEntityService,
 		FlashLikeEntityService,
 		RoleEntityService,
+		ReversiGameEntityService,
+
 		ApAudienceService,
 		ApDbResolverService,
 		ApDeliverManagerService,
@@ -726,6 +748,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$FanoutTimelineEndpointService,
 		$ChannelFollowingService,
 		$RegistryApiService,
+		$ReversiService,
+
 		$FederationChart,
 		$NotesChart,
 		$UsersChart,
@@ -739,6 +763,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$PerUserDriveChart,
 		$ApRequestChart,
 		$ChartManagementService,
+
 		$AbuseUserReportEntityService,
 		$AntennaEntityService,
 		$AppEntityService,
@@ -771,6 +796,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$FlashEntityService,
 		$FlashLikeEntityService,
 		$RoleEntityService,
+		$ReversiGameEntityService,
+
 		$ApAudienceService,
 		$ApDbResolverService,
 		$ApDeliverManagerService,
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index d175f21f2ff7874b4787bb95bf2817e6eacac1b1..11a8935be2b7c2c4b5d013a253b1e36ecdc7cbb7 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -18,7 +18,7 @@ import type { MiSignin } from '@/models/Signin.js';
 import type { MiPage } from '@/models/Page.js';
 import type { MiWebhook } from '@/models/Webhook.js';
 import type { MiMeta } from '@/models/Meta.js';
-import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js';
+import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
 import type { Packed } from '@/misc/json-schema.js';
 import { DI } from '@/di-symbols.js';
 import type { Config } from '@/config.js';
@@ -159,6 +159,43 @@ export interface AdminEventTypes {
 		comment: string;
 	};
 }
+
+export interface ReversiEventTypes {
+	matched: {
+		game: Packed<'ReversiGameDetailed'>;
+	};
+	invited: {
+		user: Packed<'User'>;
+	};
+}
+
+export interface ReversiGameEventTypes {
+	changeReadyStates: {
+		user1: boolean;
+		user2: boolean;
+	};
+	updateSettings: {
+		userId: MiUser['id'];
+		key: string;
+		value: any;
+	};
+	putStone: {
+		at: number;
+		color: boolean;
+		pos: number;
+		next: boolean;
+	};
+	syncState: {
+		crc32: string;
+	};
+	started: {
+		game: Packed<'ReversiGameDetailed'>;
+	};
+	ended: {
+		winnerId: MiUser['id'] | null;
+		game: Packed<'ReversiGameDetailed'>;
+	};
+}
 //#endregion
 
 // 辞書(interface or type)から{ type, body }ユニオンを定義
@@ -249,6 +286,14 @@ export type GlobalEvents = {
 		name: 'notesStream';
 		payload: Serialized<Packed<'Note'>>;
 	};
+	reversi: {
+		name: `reversiStream:${MiUser['id']}`;
+		payload: EventUnionFromDictionary<SerializedAll<ReversiEventTypes>>;
+	};
+	reversiGame: {
+		name: `reversiGameStream:${MiReversiGame['id']}`;
+		payload: EventUnionFromDictionary<SerializedAll<ReversiGameEventTypes>>;
+	};
 };
 
 // API event definitions
@@ -338,4 +383,14 @@ export class GlobalEventService {
 	public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
 		this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
 	}
+
+	@bindThis
+	public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
+		this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	@bindThis
+	public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
+		this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
+	}
 }
diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cd990ba775a1a49ec2c7071bb14a7b831b579821
--- /dev/null
+++ b/packages/backend/src/core/ReversiService.ts
@@ -0,0 +1,411 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import CRC32 from 'crc-32';
+import { ModuleRef } from '@nestjs/core';
+import * as Reversi from 'misskey-reversi';
+import { IsNull } from 'typeorm';
+import type {
+	MiReversiGame,
+	ReversiGamesRepository,
+	UsersRepository,
+} from '@/models/_.js';
+import type { MiUser } from '@/models/User.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { IdService } from '@/core/IdService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { NotificationService } from '@/core/NotificationService.js';
+import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
+import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
+
+const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec
+
+@Injectable()
+export class ReversiService implements OnApplicationShutdown, OnModuleInit {
+	private notificationService: NotificationService;
+
+	constructor(
+		private moduleRef: ModuleRef,
+
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
+		@Inject(DI.reversiGamesRepository)
+		private reversiGamesRepository: ReversiGamesRepository,
+
+		private cacheService: CacheService,
+		private userEntityService: UserEntityService,
+		private globalEventService: GlobalEventService,
+		private reversiGameEntityService: ReversiGameEntityService,
+		private idService: IdService,
+	) {
+	}
+
+	async onModuleInit() {
+		this.notificationService = this.moduleRef.get(NotificationService.name);
+	}
+
+	@bindThis
+	public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
+		if (targetUser.id === me.id) {
+			throw new Error('You cannot match yourself.');
+		}
+
+		const invitations = await this.redisClient.zrange(
+			`reversi:matchSpecific:${me.id}`,
+			Date.now() - MATCHING_TIMEOUT_MS,
+			'+inf',
+			'BYSCORE');
+
+		if (invitations.includes(targetUser.id)) {
+			await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
+
+			const game = await this.reversiGamesRepository.insert({
+				id: this.idService.gen(),
+				user1Id: targetUser.id,
+				user2Id: me.id,
+				user1Ready: false,
+				user2Ready: false,
+				isStarted: false,
+				isEnded: false,
+				logs: [],
+				map: Reversi.maps.eighteight.data,
+				bw: 'random',
+				isLlotheo: false,
+			}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
+
+			const packed = await this.reversiGameEntityService.packDetail(game, { id: targetUser.id });
+			this.globalEventService.publishReversiStream(targetUser.id, 'matched', { game: packed });
+
+			return game;
+		} else {
+			this.redisClient.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
+
+			this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
+				user: await this.userEntityService.pack(me, targetUser),
+			});
+
+			return null;
+		}
+	}
+
+	@bindThis
+	public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> {
+		//#region まず自分宛ての招待を探す
+		const invitations = await this.redisClient.zrange(
+			`reversi:matchSpecific:${me.id}`,
+			Date.now() - MATCHING_TIMEOUT_MS,
+			'+inf',
+			'BYSCORE');
+
+		if (invitations.length > 0) {
+			const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
+			await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
+
+			const game = await this.reversiGamesRepository.insert({
+				id: this.idService.gen(),
+				user1Id: invitorId,
+				user2Id: me.id,
+				user1Ready: false,
+				user2Ready: false,
+				isStarted: false,
+				isEnded: false,
+				logs: [],
+				map: Reversi.maps.eighteight.data,
+				bw: 'random',
+				isLlotheo: false,
+			}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
+
+			const packed = await this.reversiGameEntityService.packDetail(game, { id: invitorId });
+			this.globalEventService.publishReversiStream(invitorId, 'matched', { game: packed });
+
+			return game;
+		}
+		//#endregion
+
+		const matchings = await this.redisClient.zrange(
+			'reversi:matchAny',
+			Date.now() - MATCHING_TIMEOUT_MS,
+			'+inf',
+			'BYSCORE');
+
+		const userIds = matchings.filter(id => id !== me.id);
+
+		if (userIds.length > 0) {
+			// pick random
+			const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
+
+			await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
+
+			const game = await this.reversiGamesRepository.insert({
+				id: this.idService.gen(),
+				user1Id: matchedUserId,
+				user2Id: me.id,
+				user1Ready: false,
+				user2Ready: false,
+				isStarted: false,
+				isEnded: false,
+				logs: [],
+				map: Reversi.maps.eighteight.data,
+				bw: 'random',
+				isLlotheo: false,
+			}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
+
+			const packed = await this.reversiGameEntityService.packDetail(game, { id: matchedUserId });
+			this.globalEventService.publishReversiStream(matchedUserId, 'matched', { game: packed });
+
+			return game;
+		} else {
+			await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id);
+			return null;
+		}
+	}
+
+	@bindThis
+	public async matchSpecificUserCancel(user: MiUser, targetUserId: MiUser['id']) {
+		await this.redisClient.zrem(`reversi:matchSpecific:${targetUserId}`, user.id);
+	}
+
+	@bindThis
+	public async matchAnyUserCancel(user: MiUser) {
+		await this.redisClient.zrem('reversi:matchAny', user.id);
+	}
+
+	@bindThis
+	public async gameReady(game: MiReversiGame, user: MiUser, ready: boolean) {
+		if (game.isStarted) return;
+
+		let isBothReady = false;
+
+		if (game.user1Id === user.id) {
+			await this.reversiGamesRepository.update(game.id, {
+				user1Ready: ready,
+			});
+
+			this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
+				user1: ready,
+				user2: game.user2Ready,
+			});
+
+			if (ready && game.user2Ready) isBothReady = true;
+		} else if (game.user2Id === user.id) {
+			await this.reversiGamesRepository.update(game.id, {
+				user2Ready: ready,
+			});
+
+			this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
+				user1: game.user1Ready,
+				user2: ready,
+			});
+
+			if (ready && game.user1Ready) isBothReady = true;
+		} else {
+			return;
+		}
+
+		if (isBothReady) {
+			// 3秒後、両者readyならゲーム開始
+			setTimeout(async () => {
+				const freshGame = await this.reversiGamesRepository.findOneBy({ id: game.id });
+				if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
+				if (!freshGame.user1Ready || !freshGame.user2Ready) return;
+
+				let bw: number;
+				if (freshGame.bw === 'random') {
+					bw = Math.random() > 0.5 ? 1 : 2;
+				} else {
+					bw = parseInt(freshGame.bw, 10);
+				}
+
+				function getRandomMap() {
+					const mapCount = Object.entries(Reversi.maps).length;
+					const rnd = Math.floor(Math.random() * mapCount);
+					return Object.values(Reversi.maps)[rnd].data;
+				}
+
+				const map = freshGame.map != null ? freshGame.map : getRandomMap();
+
+				await this.reversiGamesRepository.update(game.id, {
+					startedAt: new Date(),
+					isStarted: true,
+					black: bw,
+					map: map,
+				});
+
+				//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
+				const o = new Reversi.Game(map, {
+					isLlotheo: freshGame.isLlotheo,
+					canPutEverywhere: freshGame.canPutEverywhere,
+					loopedBoard: freshGame.loopedBoard,
+				});
+
+				if (o.isEnded) {
+					let winner;
+					if (o.winner === true) {
+						winner = freshGame.black === 1 ? freshGame.user1Id : freshGame.user2Id;
+					} else if (o.winner === false) {
+						winner = freshGame.black === 1 ? freshGame.user2Id : freshGame.user1Id;
+					} else {
+						winner = null;
+					}
+
+					await this.reversiGamesRepository.update(game.id, {
+						isEnded: true,
+						winnerId: winner,
+					});
+
+					this.globalEventService.publishReversiGameStream(game.id, 'ended', {
+						winnerId: winner,
+						game: await this.reversiGameEntityService.packDetail(game.id, user),
+					});
+				}
+				//#endregion
+
+				this.globalEventService.publishReversiGameStream(game.id, 'started', {
+					game: await this.reversiGameEntityService.packDetail(game.id, user),
+				});
+			}, 3000);
+		}
+	}
+
+	@bindThis
+	public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
+		const invitations = await this.redisClient.zrange(
+			`reversi:matchSpecific:${user.id}`,
+			Date.now() - MATCHING_TIMEOUT_MS,
+			'+inf',
+			'BYSCORE');
+		return invitations;
+	}
+
+	@bindThis
+	public async updateSettings(game: MiReversiGame, user: MiUser, key: string, value: any) {
+		if (game.isStarted) return;
+		if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
+		if ((game.user1Id === user.id) && game.user1Ready) return;
+		if ((game.user2Id === user.id) && game.user2Ready) return;
+
+		if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return;
+
+		await this.reversiGamesRepository.update(game.id, {
+			[key]: value,
+		});
+
+		this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', {
+			userId: user.id,
+			key: key,
+			value: value,
+		});
+	}
+
+	@bindThis
+	public async putStoneToGame(game: MiReversiGame, user: MiUser, pos: number) {
+		if (!game.isStarted) return;
+		if (game.isEnded) return;
+		if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
+
+		const myColor =
+			((game.user1Id === user.id) && game.black === 1) || ((game.user2Id === user.id) && game.black === 2)
+				? true
+				: false;
+
+		const o = new Reversi.Game(game.map, {
+			isLlotheo: game.isLlotheo,
+			canPutEverywhere: game.canPutEverywhere,
+			loopedBoard: game.loopedBoard,
+		});
+
+		// 盤面の状態を再生
+		for (const log of game.logs) {
+			o.put(log.color, log.pos);
+		}
+
+		if (o.turn !== myColor) return;
+
+		if (!o.canPut(myColor, pos)) return;
+		o.put(myColor, pos);
+
+		let winner;
+		if (o.isEnded) {
+			if (o.winner === true) {
+				winner = game.black === 1 ? game.user1Id : game.user2Id;
+			} else if (o.winner === false) {
+				winner = game.black === 1 ? game.user2Id : game.user1Id;
+			} else {
+				winner = null;
+			}
+		}
+
+		const log = {
+			at: Date.now(),
+			color: myColor,
+			pos,
+		};
+
+		const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString();
+
+		game.logs.push(log);
+
+		await this.reversiGamesRepository.update(game.id, {
+			crc32,
+			isEnded: o.isEnded,
+			winnerId: winner,
+			logs: game.logs,
+		});
+
+		this.globalEventService.publishReversiGameStream(game.id, 'putStone', {
+			...log,
+			next: o.turn,
+		});
+
+		if (o.isEnded) {
+			this.globalEventService.publishReversiGameStream(game.id, 'ended', {
+				winnerId: winner,
+				game: await this.reversiGameEntityService.packDetail(game.id, user),
+			});
+		}
+	}
+
+	@bindThis
+	public async surrender(game: MiReversiGame, user: MiUser) {
+		if (game.isEnded) return;
+		if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
+
+		const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
+
+		await this.reversiGamesRepository.update(game.id, {
+			surrendered: user.id,
+			isEnded: true,
+			winnerId: winnerId,
+		});
+
+		this.globalEventService.publishReversiGameStream(game.id, 'ended', {
+			winnerId: winnerId,
+			game: await this.reversiGameEntityService.packDetail(game.id, user),
+		});
+	}
+
+	@bindThis
+	public async get(id: MiReversiGame['id']) {
+		return this.reversiGamesRepository.findOneBy({ id });
+	}
+
+	@bindThis
+	public dispose(): void {
+	}
+
+	@bindThis
+	public onApplicationShutdown(signal?: string | undefined): void {
+		this.dispose();
+	}
+}
diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8d95204928a754f415ee0c8d7c42d6c579318a7f
--- /dev/null
+++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts
@@ -0,0 +1,115 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { ReversiGamesRepository } from '@/models/_.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { } from '@/models/Blocking.js';
+import type { MiUser } from '@/models/User.js';
+import type { MiReversiGame } from '@/models/ReversiGame.js';
+import { bindThis } from '@/decorators.js';
+import { IdService } from '@/core/IdService.js';
+import { UserEntityService } from './UserEntityService.js';
+
+@Injectable()
+export class ReversiGameEntityService {
+	constructor(
+		@Inject(DI.reversiGamesRepository)
+		private reversiGamesRepository: ReversiGamesRepository,
+
+		private userEntityService: UserEntityService,
+		private idService: IdService,
+	) {
+	}
+
+	@bindThis
+	public async packDetail(
+		src: MiReversiGame['id'] | MiReversiGame,
+		me?: { id: MiUser['id'] } | null | undefined,
+	): Promise<Packed<'ReversiGameDetailed'>> {
+		const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
+
+		return await awaitAll({
+			id: game.id,
+			createdAt: this.idService.parse(game.id).date.toISOString(),
+			startedAt: game.startedAt && game.startedAt.toISOString(),
+			isStarted: game.isStarted,
+			isEnded: game.isEnded,
+			form1: game.form1,
+			form2: game.form2,
+			user1Ready: game.user1Ready,
+			user2Ready: game.user2Ready,
+			user1Id: game.user1Id,
+			user2Id: game.user2Id,
+			user1: this.userEntityService.pack(game.user1Id, me),
+			user2: this.userEntityService.pack(game.user2Id, me),
+			winnerId: game.winnerId,
+			winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
+			surrendered: game.surrendered,
+			black: game.black,
+			bw: game.bw,
+			isLlotheo: game.isLlotheo,
+			canPutEverywhere: game.canPutEverywhere,
+			loopedBoard: game.loopedBoard,
+			logs: game.logs.map(log => ({
+				at: log.at,
+				color: log.color,
+				pos: log.pos,
+			})),
+			map: game.map,
+		});
+	}
+
+	@bindThis
+	public packDetailMany(
+		xs: MiReversiGame[],
+		me?: { id: MiUser['id'] } | null | undefined,
+	) {
+		return Promise.all(xs.map(x => this.packDetail(x, me)));
+	}
+
+	@bindThis
+	public async packLite(
+		src: MiReversiGame['id'] | MiReversiGame,
+		me?: { id: MiUser['id'] } | null | undefined,
+	): Promise<Packed<'ReversiGameLite'>> {
+		const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
+
+		return await awaitAll({
+			id: game.id,
+			createdAt: this.idService.parse(game.id).date.toISOString(),
+			startedAt: game.startedAt && game.startedAt.toISOString(),
+			isStarted: game.isStarted,
+			isEnded: game.isEnded,
+			form1: game.form1,
+			form2: game.form2,
+			user1Ready: game.user1Ready,
+			user2Ready: game.user2Ready,
+			user1Id: game.user1Id,
+			user2Id: game.user2Id,
+			user1: this.userEntityService.pack(game.user1Id, me),
+			user2: this.userEntityService.pack(game.user2Id, me),
+			winnerId: game.winnerId,
+			winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
+			surrendered: game.surrendered,
+			black: game.black,
+			bw: game.bw,
+			isLlotheo: game.isLlotheo,
+			canPutEverywhere: game.canPutEverywhere,
+			loopedBoard: game.loopedBoard,
+		});
+	}
+
+	@bindThis
+	public packLiteMany(
+		xs: MiReversiGame[],
+		me?: { id: MiUser['id'] } | null | undefined,
+	) {
+		return Promise.all(xs.map(x => this.packLite(x, me)));
+	}
+}
+
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index e29fee3f96bd89ada2d8bfd8e1d29ad8a8d0f8e6..73de01f33aa07bfe0c6b7165c399524a3f07428f 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -79,5 +79,6 @@ export const DI = {
 	flashLikesRepository: Symbol('flashLikesRepository'),
 	userMemosRepository: Symbol('userMemosRepository'),
 	bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
+	reversiGamesRepository: Symbol('reversiGamesRepository'),
 	//#endregion
 };
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 176978d35f63c3a962bf09dca07f4ed121d864d2..b4f054171287ceacdc242d3f5f9834b9c2295981 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -39,6 +39,7 @@ import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
 import { packedSigninSchema } from '@/models/json-schema/signin.js';
 import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js';
 import { packedAdSchema } from '@/models/json-schema/ad.js';
+import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
 
 export const refs = {
 	UserLite: packedUserLiteSchema,
@@ -78,6 +79,8 @@ export const refs = {
 	Signin: packedSigninSchema,
 	RoleLite: packedRoleLiteSchema,
 	Role: packedRoleSchema,
+	ReversiGameLite: packedReversiGameLiteSchema,
+	ReversiGameDetailed: packedReversiGameDetailedSchema,
 };
 
 export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 0399536c3eaa0b3b15401a2bb0bb396c00141252..2b2aaeb91ce148aa8add50ae513ab22c45113cd2 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -5,7 +5,7 @@
 
 import { Module } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
-import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord } from './_.js';
+import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame } from './_.js';
 import type { DataSource } from 'typeorm';
 import type { Provider } from '@nestjs/common';
 
@@ -399,12 +399,18 @@ const $userMemosRepository: Provider = {
 	inject: [DI.db],
 };
 
-export const $bubbleGameRecordsRepository: Provider = {
+const $bubbleGameRecordsRepository: Provider = {
 	provide: DI.bubbleGameRecordsRepository,
 	useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord),
 	inject: [DI.db],
 };
 
+const $reversiGamesRepository: Provider = {
+	provide: DI.reversiGamesRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiReversiGame),
+	inject: [DI.db],
+};
+
 @Module({
 	imports: [
 	],
@@ -475,6 +481,7 @@ export const $bubbleGameRecordsRepository: Provider = {
 		$flashLikesRepository,
 		$userMemosRepository,
 		$bubbleGameRecordsRepository,
+		$reversiGamesRepository,
 	],
 	exports: [
 		$usersRepository,
@@ -543,6 +550,7 @@ export const $bubbleGameRecordsRepository: Provider = {
 		$flashLikesRepository,
 		$userMemosRepository,
 		$bubbleGameRecordsRepository,
+		$reversiGamesRepository,
 	],
 })
 export class RepositoryModule {}
diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d297d1f01df4d7901a66b7709ab600dc9f55f239
--- /dev/null
+++ b/packages/backend/src/models/ReversiGame.ts
@@ -0,0 +1,127 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('reversi_game')
+export class MiReversiGame {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone', {
+		nullable: true,
+		comment: 'The started date of the ReversiGame.',
+	})
+	public startedAt: Date | null;
+
+	@Column(id())
+	public user1Id: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user1: MiUser | null;
+
+	@Column(id())
+	public user2Id: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user2: MiUser | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public user1Ready: boolean;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public user2Ready: boolean;
+
+	/**
+	 * どちらのプレイヤーが先行(黒)か
+	 * 1 ... user1
+	 * 2 ... user2
+	 */
+	@Column('integer', {
+		nullable: true,
+	})
+	public black: number | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isStarted: boolean;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isEnded: boolean;
+
+	@Column({
+		...id(),
+		nullable: true,
+	})
+	public winnerId: MiUser['id'] | null;
+
+	@Column({
+		...id(),
+		nullable: true,
+	})
+	public surrendered: MiUser['id'] | null;
+
+	@Column('jsonb', {
+		default: [],
+	})
+	public logs: {
+		at: number;
+		color: boolean;
+		pos: number;
+	}[];
+
+	@Column('varchar', {
+		array: true, length: 64,
+	})
+	public map: string[];
+
+	@Column('varchar', {
+		length: 32,
+	})
+	public bw: string;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isLlotheo: boolean;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public canPutEverywhere: boolean;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public loopedBoard: boolean;
+
+	@Column('jsonb', {
+		nullable: true, default: null,
+	})
+	public form1: any | null;
+
+	@Column('jsonb', {
+		nullable: true, default: null,
+	})
+	public form2: any | null;
+
+	/**
+	 * ログのposを文字列としてすべて連結したもののCRC32値
+	 */
+	@Column('varchar', {
+		length: 32, nullable: true,
+	})
+	public crc32: string | null;
+}
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index a1c4b0743e49ec87405e835c2b79d460314eeffe..a1a0d8823dbef7bb0cfbd29c8fbe6464d9d5a640 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -69,6 +69,8 @@ import { MiFlash } from '@/models/Flash.js';
 import { MiFlashLike } from '@/models/FlashLike.js';
 import { MiUserListFavorite } from '@/models/UserListFavorite.js';
 import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
+import { MiReversiGame } from '@/models/ReversiGame.js';
+
 import type { Repository } from 'typeorm';
 
 export {
@@ -138,6 +140,7 @@ export {
 	MiFlashLike,
 	MiUserMemo,
 	MiBubbleGameRecord,
+	MiReversiGame,
 };
 
 export type AbuseUserReportsRepository = Repository<MiAbuseUserReport>;
@@ -206,3 +209,4 @@ export type FlashsRepository = Repository<MiFlash>;
 export type FlashLikesRepository = Repository<MiFlashLike>;
 export type UserMemoRepository = Repository<MiUserMemo>;
 export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord>;
+export type ReversiGamesRepository = Repository<MiReversiGame>;
diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0d23b9dc79358a4a9cbcf93ad1160bc216d1d390
--- /dev/null
+++ b/packages/backend/src/models/json-schema/reversi-game.ts
@@ -0,0 +1,234 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedReversiGameLiteSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+		},
+		createdAt: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'date-time',
+		},
+		startedAt: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'date-time',
+		},
+		isStarted: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		isEnded: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		form1: {
+			type: 'any',
+			optional: false, nullable: true,
+		},
+		form2: {
+			type: 'any',
+			optional: false, nullable: true,
+		},
+		user1Ready: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		user2Ready: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		user1Id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+		},
+		user2Id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+		},
+		user1: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'User',
+		},
+		user2: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'User',
+		},
+		winnerId: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'id',
+		},
+		winner: {
+			type: 'object',
+			optional: false, nullable: true,
+			ref: 'User',
+		},
+		surrendered: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'id',
+		},
+		black: {
+			type: 'number',
+			optional: false, nullable: true,
+		},
+		bw: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		isLlotheo: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		canPutEverywhere: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		loopedBoard: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+	},
+} as const;
+
+export const packedReversiGameDetailedSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+		},
+		createdAt: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'date-time',
+		},
+		startedAt: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'date-time',
+		},
+		isStarted: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		isEnded: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		form1: {
+			type: 'any',
+			optional: false, nullable: true,
+		},
+		form2: {
+			type: 'any',
+			optional: false, nullable: true,
+		},
+		user1Ready: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		user2Ready: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		user1Id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+		},
+		user2Id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+		},
+		user1: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'User',
+		},
+		user2: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'User',
+		},
+		winnerId: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'id',
+		},
+		winner: {
+			type: 'object',
+			optional: false, nullable: true,
+			ref: 'User',
+		},
+		surrendered: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'id',
+		},
+		black: {
+			type: 'number',
+			optional: false, nullable: true,
+		},
+		bw: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		isLlotheo: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		canPutEverywhere: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		loopedBoard: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		logs: {
+			type: 'array',
+			optional: false, nullable: false,
+			items: {
+				type: 'object',
+				optional: false, nullable: false,
+				properties: {
+					at: {
+						type: 'number',
+						optional: false, nullable: false,
+					},
+					color: {
+						type: 'boolean',
+						optional: false, nullable: false,
+					},
+					pos: {
+						type: 'number',
+						optional: false, nullable: false,
+					},
+				},
+			},
+		},
+		map: {
+			type: 'array',
+			optional: false, nullable: false,
+			items: {
+				type: 'string',
+				optional: false, nullable: false,
+			},
+		},
+	},
+} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 0430e9ca19d5b67894320e78b333b31dd1884a18..1e063c86731b57982fbee4f309c11b23c2363ed6 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -77,6 +77,7 @@ import { MiFlash } from '@/models/Flash.js';
 import { MiFlashLike } from '@/models/FlashLike.js';
 import { MiUserMemo } from '@/models/UserMemo.js';
 import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
+import { MiReversiGame } from '@/models/ReversiGame.js';
 
 import { Config } from '@/config.js';
 import MisskeyLogger from '@/logger.js';
@@ -192,6 +193,7 @@ export const entities = [
 	MiFlashLike,
 	MiUserMemo,
 	MiBubbleGameRecord,
+	MiReversiGame,
 	...charts,
 ];
 
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index fa81380f01048e41ce0813e4172d25b50533b66d..aed352d15e281e2cf8599ed386027b95fc5d005e 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -22,9 +22,13 @@ import { SigninApiService } from './api/SigninApiService.js';
 import { SigninService } from './api/SigninService.js';
 import { SignupApiService } from './api/SignupApiService.js';
 import { StreamingApiServerService } from './api/StreamingApiServerService.js';
+import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
 import { ClientServerService } from './web/ClientServerService.js';
 import { FeedService } from './web/FeedService.js';
 import { UrlPreviewService } from './web/UrlPreviewService.js';
+import { ClientLoggerService } from './web/ClientLoggerService.js';
+import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
+
 import { MainChannelService } from './api/stream/channels/main.js';
 import { AdminChannelService } from './api/stream/channels/admin.js';
 import { AntennaChannelService } from './api/stream/channels/antenna.js';
@@ -38,10 +42,9 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin
 import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
 import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
 import { UserListChannelService } from './api/stream/channels/user-list.js';
-import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
-import { ClientLoggerService } from './web/ClientLoggerService.js';
 import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
-import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
+import { ReversiChannelService } from './api/stream/channels/reversi.js';
+import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
 
 @Module({
 	imports: [
@@ -77,6 +80,8 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
 		GlobalTimelineChannelService,
 		HashtagChannelService,
 		RoleTimelineChannelService,
+		ReversiChannelService,
+		ReversiGameChannelService,
 		HomeTimelineChannelService,
 		HybridTimelineChannelService,
 		LocalTimelineChannelService,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 781332d34907571d7e6481bec6306ade8f47d87d..df69ce238595c804eab9a183e7b833433e37e60e 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -366,6 +366,12 @@ import * as ep___fetchExternalResources from './endpoints/fetch-external-resourc
 import * as ep___retention from './endpoints/retention.js';
 import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js';
 import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js';
+import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js';
+import * as ep___reversi_games from './endpoints/reversi/games.js';
+import * as ep___reversi_match from './endpoints/reversi/match.js';
+import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
+import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
+import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
 import { GetterService } from './GetterService.js';
 import { ApiLoggerService } from './ApiLoggerService.js';
 import type { Provider } from '@nestjs/common';
@@ -730,6 +736,12 @@ const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resource
 const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
 const $bubbleGame_register: Provider = { provide: 'ep:bubble-game/register', useClass: ep___bubbleGame_register.default };
 const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useClass: ep___bubbleGame_ranking.default };
+const $reversi_cancelMatch: Provider = { provide: 'ep:reversi/cancel-match', useClass: ep___reversi_cancelMatch.default };
+const $reversi_games: Provider = { provide: 'ep:reversi/games', useClass: ep___reversi_games.default };
+const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___reversi_match.default };
+const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useClass: ep___reversi_invitations.default };
+const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default };
+const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default };
 
 @Module({
 	imports: [
@@ -1098,6 +1110,12 @@ const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useCl
 		$retention,
 		$bubbleGame_register,
 		$bubbleGame_ranking,
+		$reversi_cancelMatch,
+		$reversi_games,
+		$reversi_match,
+		$reversi_invitations,
+		$reversi_showGame,
+		$reversi_surrender,
 	],
 	exports: [
 		$admin_meta,
@@ -1457,6 +1475,12 @@ const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useCl
 		$retention,
 		$bubbleGame_register,
 		$bubbleGame_ranking,
+		$reversi_cancelMatch,
+		$reversi_games,
+		$reversi_match,
+		$reversi_invitations,
+		$reversi_showGame,
+		$reversi_surrender,
 	],
 })
 export class EndpointsModule {}
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index f17db41a5d2b155ba09230b5232499bbaf03685f..0f2c8cb754a78944864f0833f3a85fbdd533b8d2 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -367,6 +367,12 @@ import * as ep___fetchExternalResources from './endpoints/fetch-external-resourc
 import * as ep___retention from './endpoints/retention.js';
 import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js';
 import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js';
+import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js';
+import * as ep___reversi_games from './endpoints/reversi/games.js';
+import * as ep___reversi_match from './endpoints/reversi/match.js';
+import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
+import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
+import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
 
 const eps = [
 	['admin/meta', ep___admin_meta],
@@ -729,6 +735,12 @@ const eps = [
 	['retention', ep___retention],
 	['bubble-game/register', ep___bubbleGame_register],
 	['bubble-game/ranking', ep___bubbleGame_ranking],
+	['reversi/cancel-match', ep___reversi_cancelMatch],
+	['reversi/games', ep___reversi_games],
+	['reversi/match', ep___reversi_match],
+	['reversi/invitations', ep___reversi_invitations],
+	['reversi/show-game', ep___reversi_showGame],
+	['reversi/surrender', ep___reversi_surrender],
 ];
 
 interface IEndpointMetaBase {
diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
index 7ff7b5de3a6df8f540aea0d149bf75b36c4dc5dc..2d853b94f3a416e01d56b5b8fe41a012645e33d4 100644
--- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts
+++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
@@ -73,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			}
 
 			// Get mutee
-			const mutee = await getterService.getUser(ps.userId).catch(err => {
+			const mutee = await this.getterService.getUser(ps.userId).catch(err => {
 				if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
 				throw err;
 			});
diff --git a/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts b/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8edc0495006efeb336b5cd6671088922fe784628
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts
@@ -0,0 +1,44 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+
+export const meta = {
+	requireCredential: true,
+
+	kind: 'write:account',
+
+	errors: {
+	},
+
+	res: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id', nullable: true },
+	},
+	required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private reversiService: ReversiService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			if (ps.userId) {
+				await this.reversiService.matchSpecificUserCancel(me, ps.userId);
+				return;
+			} else {
+				await this.reversiService.matchAnyUserCancel(me);
+			}
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/games.ts b/packages/backend/src/server/api/endpoints/reversi/games.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5322cd09877e41875e0279280499889e0a285402
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/games.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import { DI } from '@/di-symbols.js';
+import type { ReversiGamesRepository } from '@/models/_.js';
+import { QueryService } from '@/core/QueryService.js';
+
+export const meta = {
+	requireCredential: false,
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: { ref: 'ReversiGameLite' },
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		my: { type: 'boolean', default: false },
+	},
+	required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.reversiGamesRepository)
+		private reversiGamesRepository: ReversiGamesRepository,
+
+		private reversiGameEntityService: ReversiGameEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
+				.andWhere('game.isStarted = TRUE');
+
+			if (ps.my && me) {
+				query.andWhere(new Brackets(qb => {
+					qb
+						.where('game.user1Id = :userId', { userId: me.id })
+						.orWhere('game.user2Id = :userId', { userId: me.id });
+				}));
+			}
+
+			const games = await query.take(ps.limit).getMany();
+
+			return await this.reversiGameEntityService.packLiteMany(games, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/invitations.ts b/packages/backend/src/server/api/endpoints/reversi/invitations.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0b7107bb0dc828a8419f0a9a89e232cf15e46204
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/invitations.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ReversiService } from '@/core/ReversiService.js';
+
+export const meta = {
+	requireCredential: true,
+
+	kind: 'read:account',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: { ref: 'UserLite' },
+	},
+} as const;
+
+export const paramDef = {
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private userEntityService: UserEntityService,
+		private reversiService: ReversiService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const invitations = await this.reversiService.getInvitations(me);
+
+			return await this.userEntityService.packMany(invitations, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/match.ts b/packages/backend/src/server/api/endpoints/reversi/match.ts
new file mode 100644
index 0000000000000000000000000000000000000000..da5a3409ef6a5c99434fe63136f02cd8b64844f8
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/match.ts
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import { ApiError } from '../../error.js';
+import { GetterService } from '../../GetterService.js';
+
+export const meta = {
+	requireCredential: true,
+
+	kind: 'write:account',
+
+	errors: {
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '0b4f0559-b484-4e31-9581-3f73cee89b28',
+		},
+
+		isYourself: {
+			message: 'Target user is yourself.',
+			code: 'TARGET_IS_YOURSELF',
+			id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e',
+		},
+	},
+
+	res: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id', nullable: true },
+	},
+	required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private getterService: GetterService,
+		private reversiService: ReversiService,
+		private reversiGameEntityService: ReversiGameEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			if (ps.userId === me.id) throw new ApiError(meta.errors.isYourself);
+
+			const target = ps.userId ? await this.getterService.getUser(ps.userId).catch(err => {
+				if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+				throw err;
+			}) : null;
+
+			const game = target ? await this.reversiService.matchSpecificUser(me, target) : await this.reversiService.matchAnyUser(me);
+
+			if (game == null) return;
+
+			return await this.reversiGameEntityService.packDetail(game, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/show-game.ts b/packages/backend/src/server/api/endpoints/reversi/show-game.ts
new file mode 100644
index 0000000000000000000000000000000000000000..de571053e12d1b5b403f656b1617c4b2019ef120
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/show-game.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	requireCredential: false,
+
+	errors: {
+		noSuchGame: {
+			message: 'No such game.',
+			code: 'NO_SUCH_GAME',
+			id: 'f13a03db-fae1-46c9-87f3-43c8165419e1',
+		},
+	},
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ReversiGameDetailed',
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		gameId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['gameId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private reversiService: ReversiService,
+		private reversiGameEntityService: ReversiGameEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const game = await this.reversiService.get(ps.gameId);
+
+			if (game == null) {
+				throw new ApiError(meta.errors.noSuchGame);
+			}
+
+			return await this.reversiGameEntityService.packDetail(game, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/surrender.ts b/packages/backend/src/server/api/endpoints/reversi/surrender.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c47d36be3342885929b9cf599b00ae956eb71fd7
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/surrender.ts
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	requireCredential: true,
+
+	kind: 'write:account',
+
+	errors: {
+		noSuchGame: {
+			message: 'No such game.',
+			code: 'NO_SUCH_GAME',
+			id: 'ace0b11f-e0a6-4076-a30d-e8284c81b2df',
+		},
+
+		alreadyEnded: {
+			message: 'That game has already ended.',
+			code: 'ALREADY_ENDED',
+			id: '6c2ad4a6-cbf1-4a5b-b187-b772826cfc6d',
+		},
+
+		accessDenied: {
+			message: 'Access denied.',
+			code: 'ACCESS_DENIED',
+			id: '6e04164b-a992-4c93-8489-2123069973e1',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		gameId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['gameId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private reversiService: ReversiService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const game = await this.reversiService.get(ps.gameId);
+
+			if (game == null) {
+				throw new ApiError(meta.errors.noSuchGame);
+			}
+
+			if (game.isEnded) {
+				throw new ApiError(meta.errors.alreadyEnded);
+			}
+
+			if ((game.user1Id !== me.id) && (game.user2Id !== me.id)) {
+				throw new ApiError(meta.errors.accessDenied);
+			}
+
+			await this.reversiService.surrender(game, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index 3bc5380132a9ccfde1493480b3061f65f122329b..998429dd0a4d933193fcb610612937031de72762 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -19,6 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js';
 import { DriveChannelService } from './channels/drive.js';
 import { HashtagChannelService } from './channels/hashtag.js';
 import { RoleTimelineChannelService } from './channels/role-timeline.js';
+import { ReversiChannelService } from './channels/reversi.js';
+import { ReversiGameChannelService } from './channels/reversi-game.js';
 import { type MiChannelService } from './channel.js';
 
 @Injectable()
@@ -38,6 +40,8 @@ export class ChannelsService {
 		private serverStatsChannelService: ServerStatsChannelService,
 		private queueStatsChannelService: QueueStatsChannelService,
 		private adminChannelService: AdminChannelService,
+		private reversiChannelService: ReversiChannelService,
+		private reversiGameChannelService: ReversiGameChannelService,
 	) {
 	}
 
@@ -58,6 +62,8 @@ export class ChannelsService {
 			case 'serverStats': return this.serverStatsChannelService;
 			case 'queueStats': return this.queueStatsChannelService;
 			case 'admin': return this.adminChannelService;
+			case 'reversi': return this.reversiChannelService;
+			case 'reversiGame': return this.reversiGameChannelService;
 
 			default:
 				throw new Error(`no such channel: ${name}`);
diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c67c05fb0912b68d26b26f47e012519cf03d96fd
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import type { MiReversiGame, ReversiGamesRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ReversiGameChannel extends Channel {
+	public readonly chName = 'reversiGame';
+	public static shouldShare = false;
+	public static requireCredential = false as const;
+	private gameId: MiReversiGame['id'] | null = null;
+
+	constructor(
+		private reversiService: ReversiService,
+		private reversiGamesRepository: ReversiGamesRepository,
+		private reversiGameEntityService: ReversiGameEntityService,
+
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+	}
+
+	@bindThis
+	public async init(params: any) {
+		this.gameId = params.gameId as string;
+
+		const game = await this.reversiGamesRepository.findOneBy({
+			id: this.gameId,
+		});
+		if (game == null) return;
+
+		this.subscriber.on(`reversiGameStream:${this.gameId}`, this.send);
+	}
+
+	@bindThis
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'ready': this.ready(body); break;
+			case 'updateSettings': this.updateSettings(body.key, body.value); break;
+			case 'putStone': this.putStone(body.pos); break;
+			case 'syncState': this.syncState(body.crc32); break;
+		}
+	}
+
+	@bindThis
+	private async updateSettings(key: string, value: any) {
+		if (this.user == null) return;
+
+		// TODO: キャッシュしたい
+		const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
+		if (game == null) throw new Error('game not found');
+
+		this.reversiService.updateSettings(game, this.user, key, value);
+	}
+
+	@bindThis
+	private async ready(ready: boolean) {
+		if (this.user == null) return;
+
+		const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
+		if (game == null) throw new Error('game not found');
+
+		this.reversiService.gameReady(game, this.user, ready);
+	}
+
+	@bindThis
+	private async putStone(pos: number) {
+		if (this.user == null) return;
+
+		// TODO: キャッシュしたい
+		const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
+		if (game == null) throw new Error('game not found');
+
+		this.reversiService.putStoneToGame(game, this.user, pos);
+	}
+
+	@bindThis
+	private async syncState(crc32: string | number) {
+		// 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));
+		}
+	}
+
+	@bindThis
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off(`reversiGameStream:${this.gameId}`, this.send);
+	}
+}
+
+@Injectable()
+export class ReversiGameChannelService implements MiChannelService<false> {
+	public readonly shouldShare = ReversiGameChannel.shouldShare;
+	public readonly requireCredential = ReversiGameChannel.requireCredential;
+	public readonly kind = ReversiGameChannel.kind;
+
+	constructor(
+		@Inject(DI.reversiGamesRepository)
+		private reversiGamesRepository: ReversiGamesRepository,
+
+		private reversiService: ReversiService,
+		private reversiGameEntityService: ReversiGameEntityService,
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): ReversiGameChannel {
+		return new ReversiGameChannel(
+			this.reversiService,
+			this.reversiGamesRepository,
+			this.reversiGameEntityService,
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cb4b1b8d5aa2153ce6de66cb3ee747cbe2748a72
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/reversi.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ReversiChannel extends Channel {
+	public readonly chName = 'reversi';
+	public static shouldShare = true;
+	public static requireCredential = true as const;
+	public static kind = 'read:account';
+
+	constructor(
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+	}
+
+	@bindThis
+	public async init(params: any) {
+		this.subscriber.on(`reversiStream:${this.user!.id}`, this.send);
+	}
+
+	@bindThis
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off(`reversiStream:${this.user!.id}`, this.send);
+	}
+}
+
+@Injectable()
+export class ReversiChannelService implements MiChannelService<true> {
+	public readonly shouldShare = ReversiChannel.shouldShare;
+	public readonly requireCredential = ReversiChannel.requireCredential;
+	public readonly kind = ReversiChannel.kind;
+
+	constructor(
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): ReversiChannel {
+		return new ReversiChannel(
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/frontend/assets/reversi/logo.png b/packages/frontend/assets/reversi/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..7d807ef1dc57af5ca60cad70277d9112e307d0fc
Binary files /dev/null and b/packages/frontend/assets/reversi/logo.png differ
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 8c3ce30668fa8c5ac05f0125c8e9caa7b2152262..a9a68601fcac59cb83a958992e1c9ee6827e109a 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -41,6 +41,7 @@
 		"chartjs-plugin-zoom": "2.0.1",
 		"chromatic": "10.1.0",
 		"compare-versions": "6.1.0",
+		"crc-32": "^1.2.2",
 		"cropperjs": "2.0.0-beta.4",
 		"date-fns": "2.30.0",
 		"escape-regexp": "0.0.1",
@@ -53,6 +54,7 @@
 		"matter-js": "0.19.0",
 		"mfm-js": "0.24.0",
 		"misskey-js": "workspace:*",
+		"misskey-reversi": "workspace:*",
 		"photoswipe": "5.4.3",
 		"punycode": "2.3.1",
 		"rollup": "4.9.1",
diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue
index d9178f33625d054d5b3db428766fd41289a3c11a..22e7ed1ef700446ce0cabb84a79cdb28006f9e63 100644
--- a/packages/frontend/src/components/MkRadios.vue
+++ b/packages/frontend/src/components/MkRadios.vue
@@ -18,6 +18,9 @@ export default defineComponent({
 		watch(value, () => {
 			context.emit('update:modelValue', value.value);
 		});
+		watch(() => props.modelValue, v => {
+			value.value = v;
+		});
 		if (!context.slots.default) return null;
 		let options = context.slots.default();
 		const label = context.slots.label && context.slots.label();
diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index 33b8a9a86da3431543a263a136fcc02e8edbdb71..16416fd2e413e32cd8b719c78fc5980eb3c7226c 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -52,7 +52,7 @@ const props = defineProps<{
 }>();
 
 const emit = defineEmits<{
-	(ev: 'change', _ev: KeyboardEvent): void;
+	(ev: 'changeByUser'): void;
 	(ev: 'update:modelValue', value: string | null): void;
 }>();
 
@@ -77,7 +77,6 @@ const height =
 const focus = () => inputEl.value.focus();
 const onInput = (ev) => {
 	changed.value = true;
-	emit('change', ev);
 };
 
 const updated = () => {
@@ -136,6 +135,7 @@ function show(ev: MouseEvent) {
 			active: computed(() => v.value === option.props.value),
 			action: () => {
 				v.value = option.props.value;
+				emit('changeByUser', v.value);
 			},
 		});
 	};
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index f4aa06950d3a86a744dbb7bb1e307db3b1e76afc..ad11ba19408c92641c2eccfa1dfaa0ac504bbe2c 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -85,7 +85,7 @@ const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
 const selected = ref<Misskey.entities.UserDetailed | null>(null);
 const dialogEl = ref();
 
-const search = () => {
+function search() {
 	if (username.value === '' && host.value === '') {
 		users.value = [];
 		return;
@@ -98,9 +98,9 @@ const search = () => {
 	}).then(_users => {
 		users.value = _users;
 	});
-};
+}
 
-const ok = () => {
+function ok() {
 	if (selected.value == null) return;
 	emit('ok', selected.value);
 	dialogEl.value.close();
@@ -110,12 +110,12 @@ const ok = () => {
 	recents = recents.filter(x => x !== selected.value.id);
 	recents.unshift(selected.value.id);
 	defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
-};
+}
 
-const cancel = () => {
+function cancel() {
 	emit('cancel');
 	dialogEl.value.close();
-};
+}
 
 onMounted(() => {
 	misskeyApi('users/show', {
diff --git a/packages/frontend/src/global/router/definition.ts b/packages/frontend/src/global/router/definition.ts
index 8e1c178ea27ee36929714a2978d50faa35684354..0333770a64771beadf93e56d96d568ee42103429 100644
--- a/packages/frontend/src/global/router/definition.ts
+++ b/packages/frontend/src/global/router/definition.ts
@@ -15,6 +15,7 @@ const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
 	loadingComponent: MkLoading,
 	errorComponent: MkError,
 });
+
 const routes = [{
 	path: '/@:initUser/pages/:initPageName/view-source',
 	component: page(() => import('@/pages/page-editor/page-editor.vue')),
@@ -528,18 +529,26 @@ const routes = [{
 	path: '/timeline/antenna/:antennaId',
 	component: page(() => import('@/pages/antenna-timeline.vue')),
 	loginRequired: true,
-}, {
-	path: '/games',
-	component: page(() => import('@/pages/games.vue')),
-	loginRequired: true,
 }, {
 	path: '/clicker',
 	component: page(() => import('@/pages/clicker.vue')),
 	loginRequired: true,
+}, {
+	path: '/games',
+	component: page(() => import('@/pages/games.vue')),
+	loginRequired: false,
 }, {
 	path: '/bubble-game',
 	component: page(() => import('@/pages/drop-and-fusion.vue')),
 	loginRequired: true,
+}, {
+	path: '/reversi',
+	component: page(() => import('@/pages/reversi/index.vue')),
+	loginRequired: false,
+}, {
+	path: '/reversi/g/:gameId',
+	component: page(() => import('@/pages/reversi/game.vue')),
+	loginRequired: false,
 }, {
 	path: '/timeline',
 	component: page(() => import('@/pages/timeline.vue')),
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index a63d61bb8f6619c6b861cd8523a033bd86961300..9fc3603af0d0589d8ba3162af681f4df31d1b180 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -419,7 +419,7 @@ export function form(title, form) {
 	});
 }
 
-export async function selectUser(opts: { includeSelf?: boolean } = {}) {
+export async function selectUser(opts: { includeSelf?: boolean } = {}): Promise<Misskey.entities.UserLite> {
 	return new Promise((resolve, reject) => {
 		popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
 			includeSelf: opts.includeSelf,
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index dd3b189c9d3d6d40b1091ac7f20c41b5e0c8591e..beb2e714e0a66915de17e1a68ddf9ccdbfa3a1dd 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -123,7 +123,7 @@ function onGameEnd() {
 
 definePageMetadata({
 	title: i18n.ts.bubbleGame,
-	icon: 'ti ti-apple',
+	icon: 'ti ti-device-gamepad',
 });
 </script>
 
diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue
index 5d2482ded1bc1949930a710622991c4ca7334b41..45a135a459a58c9f9b9604266eaabd18a973eabf 100644
--- a/packages/frontend/src/pages/games.vue
+++ b/packages/frontend/src/pages/games.vue
@@ -7,10 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader/></template>
 	<MkSpacer :contentMax="800">
-		<div class="_panel">
-			<MkA to="/bubble-game">
-				<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
-			</MkA>
+		<div class="_gaps">
+			<div class="_panel">
+				<MkA to="/bubble-game">
+					<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+				</MkA>
+			</div>
+			<div class="_panel">
+				<MkA to="/reversi">
+					<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+				</MkA>
+			</div>
 		</div>
 	</MkSpacer>
 </MkStickyContainer>
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
new file mode 100644
index 0000000000000000000000000000000000000000..18fd74427c016c806b27b8c276a89fe0271c2160
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -0,0 +1,428 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkSpacer :contentMax="600">
+	<div :class="$style.root" class="_gaps">
+		<header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ i18n.ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ i18n.ts._reversi.white }})</header>
+
+		<div style="overflow: clip; line-height: 28px;">
+			<div v-if="!iAmPlayer && !game.isEnded && turnUser" class="turn">
+				<Mfm :key="'turn:' + turnUser.id" :text="i18n.t('_reversi.turnOf', { name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
+				<MkEllipsis/>
+			</div>
+			<div v-if="(logPos !== logs.length) && turnUser" class="turn">
+				<Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.t('_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="turn2" style="animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</div>
+			<div v-if="game.isEnded && logPos == logs.length" class="result">
+				<template v-if="game.winner">
+					<Mfm :key="'won'" :text="i18n.t('_reversi.won', { name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/>
+					<span v-if="game.surrendered != null"> ({{ i18n.ts._reversi.surrendered }})</span>
+				</template>
+				<template v-else>{{ i18n.ts._reversi.drawn }}</template>
+			</div>
+		</div>
+
+		<div :class="$style.board">
+			<div v-if="showBoardLabels" :class="$style.labelsX">
+				<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
+			</div>
+			<div style="display: flex;">
+				<div v-if="showBoardLabels" :class="$style.labelsY">
+					<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
+				</div>
+				<div :class="$style.boardCells" :style="cellsStyle">
+					<div
+						v-for="(stone, i) in engine.board"
+						v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`"
+						:class="[$style.boardCell, {
+							[$style.boardCell_empty]: stone == null,
+							[$style.boardCell_none]: engine.map[i] === 'null',
+							[$style.boardCell_isEnded]: game.isEnded,
+							[$style.boardCell_myTurn]: !game.isEnded && isMyTurn,
+							[$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null,
+							[$style.boardCell_prev]: engine.prevPos === i
+						}]"
+						@click="putStone(i)"
+					>
+						<img v-if="stone === true" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="blackUser.avatarUrl">
+						<img v-if="stone === false" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="whiteUser.avatarUrl">
+					</div>
+				</div>
+				<div v-if="showBoardLabels" :class="$style.labelsY">
+					<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
+				</div>
+			</div>
+			<div v-if="showBoardLabels" :class="$style.labelsX">
+				<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
+			</div>
+		</div>
+
+		<div class="status"><b>{{ i18n.t('_reversi.turnCount', { count: logPos }) }}</b> {{ i18n.ts._reversi.black }}:{{ engine.blackCount }} {{ i18n.ts._reversi.white }}:{{ engine.whiteCount }} {{ i18n.ts._reversi.total }}:{{ engine.blackCount + engine.whiteCount }}</div>
+
+		<div v-if="!game.isEnded && iAmPlayer" class="_buttonsCenter">
+			<MkButton danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton>
+		</div>
+
+		<div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
+			<div>{{ logPos }} / {{ logs.length }}</div>
+			<div v-if="!autoplaying" class="_buttonsCenter">
+				<MkButton :disabled="logPos === 0" @click="logPos = 0"><i class="ti ti-chevrons-left"></i></MkButton>
+				<MkButton :disabled="logPos === 0" @click="logPos--"><i class="ti ti-chevron-left"></i></MkButton>
+				<MkButton :disabled="logPos === logs.length" @click="logPos++"><i class="ti ti-chevron-right"></i></MkButton>
+				<MkButton :disabled="logPos === logs.length" @click="logPos = logs.length"><i class="ti ti-chevrons-right"></i></MkButton>
+			</div>
+			<MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ti ti-player-play"></i></MkButton>
+		</div>
+
+		<div>
+			<p v-if="game.isLlotheo">{{ i18n.ts._reversi.isLlotheo }}</p>
+			<p v-if="game.loopedBoard">{{ i18n.ts._reversi.loopedMap }}</p>
+			<p v-if="game.canPutEverywhere">{{ i18n.ts._reversi.canPutEverywhere }}</p>
+		</div>
+
+		<MkA v-if="game.isEnded" :to="`/reversi`">
+			<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; width: 200px; margin: auto;"/>
+		</MkA>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, 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';
+import MkButton from '@/components/MkButton.vue';
+import { deepClone } from '@/scripts/clone.js';
+import { useInterval } from '@/scripts/use-interval.js';
+import { signinRequired } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { userPage } from '@/filters/user.js';
+
+const $i = signinRequired();
+
+const props = defineProps<{
+	game: Misskey.entities.ReversiGameDetailed;
+	connection: Misskey.ChannelConnection;
+}>();
+
+const showBoardLabels = true;
+const autoplaying = ref<boolean>(false);
+const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
+const logs = ref<Misskey.entities.ReversiLog[]>(game.value.logs);
+const logPos = ref<number>(logs.value.length);
+const engine = shallowRef<Reversi.Game>(new Reversi.Game(game.value.map, {
+	isLlotheo: game.value.isLlotheo,
+	canPutEverywhere: game.value.canPutEverywhere,
+	loopedBoard: game.value.loopedBoard,
+}));
+
+for (const log of game.value.logs) {
+	engine.value.put(log.color, log.pos);
+}
+
+const iAmPlayer = computed(() => {
+	return game.value.user1Id === $i.id || game.value.user2Id === $i.id;
+});
+
+const myColor = computed(() => {
+	if (!iAmPlayer.value) return null;
+	if (game.value.user1Id === $i.id && game.value.black === 1) return true;
+	if (game.value.user2Id === $i.id && game.value.black === 2) return true;
+	return false;
+});
+
+const opColor = computed(() => {
+	if (!iAmPlayer.value) return null;
+	return !myColor.value;
+});
+
+const blackUser = computed(() => {
+	return game.value.black === 1 ? game.value.user1 : game.value.user2;
+});
+
+const whiteUser = computed(() => {
+	return game.value.black === 1 ? game.value.user2 : game.value.user1;
+});
+
+const turnUser = computed(() => {
+	if (engine.value.turn === true) {
+		return game.value.black === 1 ? game.value.user1 : game.value.user2;
+	} else if (engine.value.turn === false) {
+		return game.value.black === 1 ? game.value.user2 : game.value.user1;
+	} else {
+		return null;
+	}
+});
+
+const isMyTurn = computed(() => {
+	if (!iAmPlayer.value) return false;
+	const u = turnUser.value;
+	if (u == null) return false;
+	return u.id === $i.id;
+});
+
+const cellsStyle = computed(() => {
+	return {
+		'grid-template-rows': `repeat(${game.value.map.length}, 1fr)`,
+		'grid-template-columns': `repeat(${game.value.map[0].length}, 1fr)`,
+	};
+});
+
+watch(logPos, (v) => {
+	if (!game.value.isEnded) return;
+	const _o = new Reversi.Game(game.value.map, {
+		isLlotheo: game.value.isLlotheo,
+		canPutEverywhere: game.value.canPutEverywhere,
+		loopedBoard: game.value.loopedBoard,
+	});
+	for (const log of logs.value.slice(0, v)) {
+		_o.put(log.color, log.pos);
+	}
+	engine.value = _o;
+});
+
+if (game.value.isStarted && !game.value.isEnded) {
+	useInterval(() => {
+		if (game.value.isEnded) return;
+		const crc32 = CRC32.str(logs.value.map(x => x.pos.toString()).join(''));
+		props.connection.send('syncState', {
+			crc32: crc32,
+		});
+	}, 5000, { immediate: false, afterMounted: true });
+}
+
+function putStone(pos) {
+	if (game.value.isEnded) return;
+	if (!iAmPlayer.value) return;
+	if (!isMyTurn.value) return;
+	if (!engine.value.canPut(myColor.value!, pos)) return;
+
+	engine.value.put(myColor.value!, pos);
+	triggerRef(engine);
+
+	// サウンドを再生する
+	//sound.play(myColor.value ? 'reversiPutBlack' : 'reversiPutWhite');
+
+	props.connection.send('putStone', {
+		pos: pos,
+	});
+
+	checkEnd();
+}
+
+function onPutStone(x) {
+	logs.value.push(x);
+	logPos.value++;
+	engine.value.put(x.color, x.pos);
+	triggerRef(engine);
+	checkEnd();
+
+	// サウンドを再生する
+	if (x.color !== myColor.value) {
+		//sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+	}
+}
+
+function onEnded(x) {
+	game.value = deepClone(x.game);
+}
+
+function checkEnd() {
+	game.value.isEnded = engine.value.isEnded;
+	if (game.value.isEnded) {
+		if (engine.value.winner === true) {
+			game.value.winnerId = game.value.black === 1 ? game.value.user1Id : game.value.user2Id;
+			game.value.winner = game.value.black === 1 ? game.value.user1 : game.value.user2;
+		} else if (engine.value.winner === false) {
+			game.value.winnerId = game.value.black === 1 ? game.value.user2Id : game.value.user1Id;
+			game.value.winner = game.value.black === 1 ? game.value.user2 : game.value.user1;
+		} else {
+			game.value.winnerId = null;
+			game.value.winner = null;
+		}
+	}
+}
+
+function onRescue(_game) {
+	game.value = deepClone(_game);
+
+	engine.value = new Reversi.Game(game.value.map, {
+		isLlotheo: game.value.isLlotheo,
+		canPutEverywhere: game.value.canPutEverywhere,
+		loopedBoard: game.value.loopedBoard,
+	});
+
+	for (const log of game.value.logs) {
+		engine.value.put(log.color, log.pos);
+	}
+
+	triggerRef(engine);
+
+	logs.value = game.value.logs;
+	logPos.value = logs.value.length;
+
+	checkEnd();
+}
+
+function surrender() {
+	misskeyApi('reversi/surrender', {
+		gameId: game.value.id,
+	});
+}
+
+function autoplay() {
+	autoplaying.value = true;
+	logPos.value = 0;
+
+	window.setTimeout(() => {
+		logPos.value = 1;
+
+		let i = 1;
+		let previousLog = game.value.logs[0];
+		const tick = () => {
+			const log = game.value.logs[i];
+			const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime();
+			setTimeout(() => {
+				i++;
+				logPos.value++;
+				previousLog = log;
+
+				if (i < game.value.logs.length) {
+					tick();
+				} else {
+					autoplaying.value = false;
+				}
+			}, time);
+		};
+
+		tick();
+	}, 1000);
+}
+
+onMounted(() => {
+	props.connection.on('putStone', onPutStone);
+	props.connection.on('rescue', onRescue);
+	props.connection.on('ended', onEnded);
+});
+
+onUnmounted(() => {
+	props.connection.off('putStone', onPutStone);
+	props.connection.off('rescue', onRescue);
+	props.connection.off('ended', onEnded);
+});
+</script>
+
+<style lang="scss" module>
+@use "sass:math";
+
+$label-size: 16px;
+$gap: 4px;
+
+.root {
+	text-align: center;
+}
+
+.board {
+	width: calc(100% - 16px);
+	max-width: 500px;
+	margin: 0 auto;
+}
+
+.labelsX {
+	height: $label-size;
+	padding: 0 $label-size;
+	display: flex;
+}
+
+.labelsXLabel {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	font-size: 0.8em;
+
+	&:first-child {
+		margin-left: -(math.div($gap, 2));
+	}
+
+	&:last-child {
+		margin-right: -(math.div($gap, 2));
+	}
+}
+
+.labelsY {
+	width: $label-size;
+	display: flex;
+	flex-direction: column;
+}
+
+.labelsYLabel {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	font-size: 12px;
+
+	&:first-child {
+		margin-top: -(math.div($gap, 2));
+	}
+
+	&:last-child {
+		margin-bottom: -(math.div($gap, 2));
+	}
+}
+
+.boardCells {
+	flex: 1;
+	display: grid;
+	grid-gap: $gap;
+}
+
+.boardCell {
+	background: transparent;
+	border-radius: 6px;
+	overflow: clip;
+
+	&.boardCell_empty {
+		border: solid 2px var(--divider);
+	}
+
+	&.boardCell_empty.boardCell_can {
+		border-color: var(--accent);
+		opacity: 0.5;
+	}
+
+	&.boardCell_empty.boardCell_myTurn {
+		border-color: var(--divider);
+		opacity: 1;
+
+		&.boardCell_can {
+			border-color: var(--accent);
+			cursor: pointer;
+
+			&:hover {
+				background: var(--accent);
+			}
+		}
+	}
+
+	&.boardCell_prev {
+		box-shadow: 0 0 0 4px var(--accent);
+	}
+
+	&.boardCell_isEnded {
+		border-color: var(--divider);
+	}
+
+	&.boardCell_none {
+		border-color: transparent !important;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue
new file mode 100644
index 0000000000000000000000000000000000000000..301a177de13bfb36a9b5754a0d80cfe1af3f7d05
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.setting.vue
@@ -0,0 +1,236 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+	<MkSpacer :contentMax="600">
+		<div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div>
+
+		<div class="_gaps">
+			<div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
+
+			<div class="_panel">
+				<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
+					<div>{{ mapName }}</div>
+					<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
+				</div>
+
+				<div style="padding: 16px;">
+					<div v-if="game.map == null"><i class="ti ti-dice"></i></div>
+					<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
+						<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
+							<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<MkFolder :defaultOpen="true">
+				<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
+
+				<MkRadios v-model="game.bw">
+					<option value="random">{{ i18n.ts.random }}</option>
+					<option :value="'1'">
+						<I18n :src="i18n.ts._reversi.blackIs" tag="span">
+							<template #name>
+								<b><MkUserName :user="game.user1"/></b>
+							</template>
+						</I18n>
+					</option>
+					<option :value="'2'">
+						<I18n :src="i18n.ts._reversi.blackIs" tag="span">
+							<template #name>
+								<b><MkUserName :user="game.user2"/></b>
+							</template>
+						</I18n>
+					</option>
+				</MkRadios>
+			</MkFolder>
+
+			<MkFolder :defaultOpen="true">
+				<template #label>{{ i18n.ts._reversi.rules }}</template>
+
+				<div class="_gaps_s">
+					<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
+					<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
+					<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
+				</div>
+			</MkFolder>
+		</div>
+	</MkSpacer>
+	<template #footer>
+		<div :class="$style.footer">
+			<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
+				<div style="text-align: center; margin-bottom: 10px;">
+					<template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
+					<template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
+					<template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
+					<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
+				</div>
+				<div class="_buttonsCenter">
+					<MkButton rounded danger @click="exit">{{ i18n.ts.cancel }}</MkButton>
+					<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
+					<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
+				</div>
+			</MkSpacer>
+		</div>
+	</template>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as Reversi from 'misskey-reversi';
+import { i18n } from '@/i18n.js';
+import { signinRequired } from '@/account.js';
+import { deepClone } from '@/scripts/clone.js';
+import MkButton from '@/components/MkButton.vue';
+import MkRadios from '@/components/MkRadios.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import * as os from '@/os.js';
+import { MenuItem } from '@/types/menu.js';
+
+const $i = signinRequired();
+
+const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category)));
+
+const props = defineProps<{
+	game: Misskey.entities.ReversiGameDetailed;
+	connection: Misskey.ChannelConnection;
+}>();
+
+const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
+const isLlotheo = ref<boolean>(false);
+const mapName = computed(() => {
+	if (game.value.map == null) return 'Random';
+	const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join(''));
+	return found ? found.name! : '-Custom-';
+});
+const isReady = computed(() => {
+	if (game.value.user1Id === $i.id && game.value.user1Ready) return true;
+	if (game.value.user2Id === $i.id && game.value.user2Ready) return true;
+	return false;
+});
+const isOpReady = computed(() => {
+	if (game.value.user1Id !== $i.id && game.value.user1Ready) return true;
+	if (game.value.user2Id !== $i.id && game.value.user2Ready) return true;
+	return false;
+});
+
+watch(() => game.value.bw, () => {
+	updateSettings('bw');
+});
+
+function chooseMap(ev: MouseEvent) {
+	const menu: MenuItem[] = [];
+
+	for (const c of mapCategories) {
+		const maps = Object.values(Reversi.maps).filter(x => x.category === c);
+		if (maps.length === 0) continue;
+		if (c != null) {
+			menu.push({
+				type: 'label',
+				text: c,
+			});
+		}
+		for (const m of maps) {
+			menu.push({
+				text: m.name!,
+				action: () => {
+					game.value.map = m.data;
+					updateSettings('map');
+				},
+			});
+		}
+	}
+
+	os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+
+function exit() {
+	props.connection.send('exit', {});
+}
+
+function ready() {
+	props.connection.send('ready', true);
+}
+
+function unready() {
+	props.connection.send('ready', false);
+}
+
+function onChangeReadyStates(states) {
+	game.value.user1Ready = states.user1;
+	game.value.user2Ready = states.user2;
+}
+
+function updateSettings(key: keyof Misskey.entities.ReversiGameDetailed) {
+	props.connection.send('updateSettings', {
+		key: key,
+		value: game.value[key],
+	});
+}
+
+function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof Misskey.entities.ReversiGameDetailed; value: any; }) {
+	if (userId === $i.id) return;
+	if (game.value[key] === value) return;
+	game.value[key] = value;
+}
+
+function onMapCellClick(pos: number, pixel: string) {
+	const x = pos % game.value.map[0].length;
+	const y = Math.floor(pos / game.value.map[0].length);
+	const newPixel =
+		pixel === ' ' ? '-' :
+		pixel === '-' ? 'b' :
+		pixel === 'b' ? 'w' :
+		' ';
+	const line = game.value.map[y].split('');
+	line[x] = newPixel;
+	game.value.map[y] = line.join('');
+	updateSettings('map');
+}
+
+props.connection.on('changeReadyStates', onChangeReadyStates);
+props.connection.on('updateSettings', onUpdateSettings);
+
+onUnmounted(() => {
+	props.connection.off('changeReadyStates', onChangeReadyStates);
+	props.connection.off('updateSettings', onUpdateSettings);
+});
+</script>
+
+<style lang="scss" module>
+.board {
+	display: grid;
+	grid-gap: 4px;
+	width: 300px;
+	height: 300px;
+	margin: 0 auto;
+	color: var(--fg);
+}
+
+.boardCell {
+	display: grid;
+	place-items: center;
+	background: transparent;
+	border: solid 2px var(--divider);
+	border-radius: 6px;
+	overflow: clip;
+	cursor: pointer;
+}
+.boardCellNone {
+	border-color: transparent;
+}
+
+.footer {
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+	background: var(--acrylicBg);
+	border-top: solid 0.5px var(--divider);
+}
+</style>
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
new file mode 100644
index 0000000000000000000000000000000000000000..dbbeb20f42a532e5e5ae69b4131e43e5611c0c02
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -0,0 +1,68 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div v-if="game == null || connection == null"><MkLoading/></div>
+<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection"/>
+<GameBoard v-else :game="game" :connection="connection"/>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import GameSetting from './game.setting.vue';
+import GameBoard from './game.board.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { useStream } from '@/stream.js';
+
+const props = defineProps<{
+	gameId: string;
+}>();
+
+const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
+const connection = shallowRef<Misskey.ChannelConnection | null>(null);
+
+watch(() => props.gameId, () => {
+	fetchGame();
+});
+
+async function fetchGame() {
+	const _game = await misskeyApi('reversi/show-game', {
+		gameId: props.gameId,
+	});
+
+	game.value = _game;
+
+	if (connection.value) {
+		connection.value.dispose();
+	}
+	connection.value = useStream().useChannel('reversiGame', {
+		gameId: game.value.id,
+	});
+	connection.value.on('started', x => {
+		game.value = x.game;
+	});
+}
+
+onMounted(() => {
+	fetchGame();
+});
+
+onUnmounted(() => {
+	if (connection.value) {
+		connection.value.dispose();
+	}
+});
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: 'Reversi',
+	icon: 'ti ti-device-gamepad',
+})));
+</script>
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c483e36c24c2adc3b9b91ddb02a27ed79be5cc68
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -0,0 +1,271 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkSpacer v-if="!matchingAny && !matchingUser" :contentMax="600">
+	<div class="_gaps">
+		<div>
+			<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+		</div>
+
+		<div class="_buttonsCenter">
+			<MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton>
+			<MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton>
+		</div>
+
+		<MkFolder v-if="invitations.length > 0" :defaultOpen="true">
+			<template #label>{{ i18n.ts.invitations }}</template>
+			<div class="_gaps_s">
+				<button v-for="user in invitations" :key="user.id" v-panel :class="$style.invitation" class="_button" tabindex="-1" @click="accept(user)">
+					<MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="user" :showIndicator="true"/>
+					<span style="margin-right: 8px;"><b><MkUserName :user="user"/></b></span>
+					<span>@{{ user.username }}</span>
+				</button>
+			</div>
+		</MkFolder>
+
+		<MkFolder v-if="$i" :defaultOpen="true">
+			<template #label>{{ i18n.ts._reversi.myGames }}</template>
+			<MkPagination :pagination="myGamesPagination">
+				<template #default="{ items }">
+					<div :class="$style.gamePreviews">
+						<MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
+							<div :class="$style.gamePreviewPlayers">
+								<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
+							</div>
+							<div :class="$style.gamePreviewFooter">
+								<span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
+								<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
+							</div>
+						</MkA>
+					</div>
+				</template>
+			</MkPagination>
+		</MkFolder>
+
+		<MkFolder :defaultOpen="true">
+			<template #label>{{ i18n.ts._reversi.allGames }}</template>
+			<MkPagination :pagination="gamesPagination">
+				<template #default="{ items }">
+					<div :class="$style.gamePreviews">
+						<MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
+							<div :class="$style.gamePreviewPlayers">
+								<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
+							</div>
+							<div :class="$style.gamePreviewFooter">
+								<span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
+								<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
+							</div>
+						</MkA>
+					</div>
+				</template>
+			</MkPagination>
+		</MkFolder>
+	</div>
+</MkSpacer>
+<MkSpacer v-else :contentMax="600">
+	<div :class="$style.waitingScreen">
+		<div v-if="matchingUser" :class="$style.waitingScreenTitle">
+			<I18n :src="i18n.ts.waitingFor" tag="span">
+				<template #x>
+					<b><MkUserName :user="matchingUser"/></b>
+				</template>
+			</I18n>
+			<MkEllipsis/>
+		</div>
+		<div v-else :class="$style.waitingScreenTitle">
+			{{ i18n.ts._reversi.lookingForPlayer }}<MkEllipsis/>
+		</div>
+		<div class="cancel">
+			<MkButton inline rounded @click="cancelMatching">{{ i18n.ts.cancel }}</MkButton>
+		</div>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, 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';
+import { useStream } from '@/stream.js';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import { i18n } from '@/i18n.js';
+import { $i } from '@/account.js';
+import MkPagination from '@/components/MkPagination.vue';
+import { useRouter } from '@/global/router/supplier.js';
+import * as os from '@/os.js';
+import { useInterval } from '@/scripts/use-interval.js';
+
+const myGamesPagination = {
+	endpoint: 'reversi/games' as const,
+	limit: 10,
+	params: {
+		my: true,
+	},
+};
+
+const gamesPagination = {
+	endpoint: 'reversi/games' as const,
+	limit: 10,
+};
+
+const router = useRouter();
+
+if ($i) {
+	const connection = useStream().useChannel('reversi');
+
+	connection.on('matched', x => {
+		startGame(x.game);
+	});
+
+	connection.on('invited', invitation => {
+		if (invitations.value.some(x => x.id === invitation.user.id)) return;
+		invitations.value.unshift(invitation.user);
+	});
+
+	onUnmounted(() => {
+		connection.dispose();
+	});
+}
+
+const invitations = ref<Misskey.entities.UserLite[]>([]);
+const matchingUser = ref<Misskey.entities.UserLite | null>(null);
+const matchingAny = ref<boolean>(false);
+
+function startGame(game: Misskey.entities.ReversiGameDetailed) {
+	matchingUser.value = null;
+	matchingAny.value = false;
+	router.push(`/reversi/g/${game.id}`);
+}
+
+async function matchHeatbeat() {
+	if (matchingUser.value) {
+		const res = await misskeyApi('reversi/match', {
+			userId: matchingUser.value.id,
+		});
+
+		if (res != null) {
+			startGame(res);
+		}
+	} else if (matchingAny.value) {
+		const res = await misskeyApi('reversi/match', {
+			userId: null,
+		});
+
+		if (res != null) {
+			startGame(res);
+		}
+	}
+}
+
+async function matchUser() {
+	const user = await os.selectUser({ local: true });
+	if (user == null) return;
+
+	matchingUser.value = user;
+
+	matchHeatbeat();
+}
+
+async function matchAny() {
+	matchingAny.value = true;
+
+	matchHeatbeat();
+}
+
+function cancelMatching() {
+	if (matchingUser.value) {
+		misskeyApi('reversi/cancel-match', { userId: matchingUser.value.id });
+		matchingUser.value = null;
+	} else if (matchingAny.value) {
+		misskeyApi('reversi/cancel-match', { userId: null });
+		matchingAny.value = false;
+	}
+}
+
+async function accept(user) {
+	const game = await misskeyApi('reversi/match', {
+		userId: user.id,
+	});
+	if (game) {
+		startGame(game);
+	}
+}
+
+useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true });
+
+onMounted(() => {
+	misskeyApi('reversi/invitations').then(_invitations => {
+		invitations.value = _invitations;
+	});
+});
+
+definePageMetadata(computed(() => ({
+	title: 'Reversi',
+	icon: 'ti ti-device-gamepad',
+})));
+</script>
+
+<style lang="scss" module>
+.invitation {
+	display: flex;
+	box-sizing: border-box;
+	width: 100%;
+	padding: 16px;
+	line-height: 32px;
+	text-align: left;
+}
+
+.gamePreviews {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	grid-gap: var(--margin);
+}
+
+.gamePreview {
+	font-size: 90%;
+	border-radius: 8px;
+	overflow: clip;
+}
+
+.gamePreviewPlayers {
+	text-align: center;
+	padding: 16px;
+	line-height: 32px;
+}
+
+.gamePreviewPlayersAvatar {
+	width: 32px;
+	height: 32px;
+
+	&:first-child {
+		margin-right: 8px;
+	}
+
+	&:last-child {
+		margin-left: 8px;
+	}
+}
+
+.gamePreviewFooter {
+	display: flex;
+	align-items: baseline;
+	border-top: solid 0.5px var(--divider);
+	padding: 6px 10px;
+	font-size: 0.9em;
+}
+
+.waitingScreen {
+	text-align: center;
+}
+
+.waitingScreenTitle {
+	font-size: 1.5em;
+	margin-bottom: 16px;
+	margin-top: 32px;
+}
+</style>
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 98fe0043c14317d42d369ac9a0d7ef30e0797625..8cdc7b59c66cfee806579ab6f0a3a000c62415fd 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -103,7 +103,7 @@ export function getConfig(): UserConfig {
 
 		// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
 		optimizeDeps: {
-			include: ['misskey-js'],
+			include: ['misskey-js', 'misskey-reversi'],
 		},
 
 		build: {
@@ -135,7 +135,7 @@ export function getConfig(): UserConfig {
 
 			// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
 			commonjsOptions: {
-				include: [/misskey-js/, /node_modules/],
+				include: [/misskey-js/, /misskey-reversi/, /node_modules/],
 			},
 		},
 
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index f955cc5cc1e4f310fc1bcbde1e26ef2f6b5b4bf7..2b95e0153385f00ca4477c68aefa0a72013fbc38 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1623,6 +1623,16 @@ declare namespace entities {
         BubbleGameRegisterResponse,
         BubbleGameRankingRequest,
         BubbleGameRankingResponse,
+        ReversiCancelMatchRequest,
+        ReversiCancelMatchResponse,
+        ReversiGamesRequest,
+        ReversiGamesResponse,
+        ReversiMatchRequest,
+        ReversiMatchResponse,
+        ReversiInvitationsResponse,
+        ReversiShowGameRequest,
+        ReversiShowGameResponse,
+        ReversiSurrenderRequest,
         Error_2 as Error,
         UserLite,
         UserDetailedNotMeOnly,
@@ -1659,7 +1669,9 @@ declare namespace entities {
         Flash,
         Signin,
         RoleLite,
-        Role
+        Role,
+        ReversiGameLite,
+        ReversiGameDetailed
     }
 }
 export { entities }
@@ -2596,6 +2608,42 @@ type ResetPasswordRequest = operations['reset-password']['requestBody']['content
 // @public (undocumented)
 type RetentionResponse = operations['retention']['responses']['200']['content']['application/json'];
 
+// @public (undocumented)
+type ReversiCancelMatchRequest = operations['reversi/cancel-match']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiCancelMatchResponse = operations['reversi/cancel-match']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiGameDetailed = components['schemas']['ReversiGameDetailed'];
+
+// @public (undocumented)
+type ReversiGameLite = components['schemas']['ReversiGameLite'];
+
+// @public (undocumented)
+type ReversiGamesRequest = operations['reversi/games']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiGamesResponse = operations['reversi/games']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiInvitationsResponse = operations['reversi/invitations']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiMatchRequest = operations['reversi/match']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiMatchResponse = operations['reversi/match']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiShowGameRequest = operations['reversi/show-game']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json'];
+
 // @public (undocumented)
 type Role = components['schemas']['Role'];
 
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index b60f449a712093d0c6df5dbad4cee8f9aa285665..e4e7d13668fc9327aa1ee9d84e1515a606378d89 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-13T04:31:38.782Z
+ * generatedAt: 2024-01-19T11:00:07.160Z
  */
 
 import type { SwitchCaseResponseType } from '../api.js';
@@ -4007,5 +4007,71 @@ declare module '../api.js' {
       params: P,
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:account*
+     */
+    request<E extends 'reversi/cancel-match', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *No*
+     */
+    request<E extends 'reversi/games', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:account*
+     */
+    request<E extends 'reversi/match', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:account*
+     */
+    request<E extends 'reversi/invitations', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *No*
+     */
+    request<E extends 'reversi/show-game', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:account*
+     */
+    request<E extends 'reversi/surrender', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
   }
 }
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index dc591a7046e24be0029e84c93cbde62fde3eacfe..671abd78ce62f3c841c08c32c049d842c53b4b33 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-13T04:31:38.778Z
+ * generatedAt: 2024-01-19T11:00:07.158Z
  */
 
 import type {
@@ -544,6 +544,16 @@ import type {
 	BubbleGameRegisterResponse,
 	BubbleGameRankingRequest,
 	BubbleGameRankingResponse,
+	ReversiCancelMatchRequest,
+	ReversiCancelMatchResponse,
+	ReversiGamesRequest,
+	ReversiGamesResponse,
+	ReversiMatchRequest,
+	ReversiMatchResponse,
+	ReversiInvitationsResponse,
+	ReversiShowGameRequest,
+	ReversiShowGameResponse,
+	ReversiSurrenderRequest,
 } from './entities.js';
 
 export type Endpoints = {
@@ -907,4 +917,10 @@ export type Endpoints = {
 	'retention': { req: EmptyRequest; res: RetentionResponse };
 	'bubble-game/register': { req: BubbleGameRegisterRequest; res: BubbleGameRegisterResponse };
 	'bubble-game/ranking': { req: BubbleGameRankingRequest; res: BubbleGameRankingResponse };
+	'reversi/cancel-match': { req: ReversiCancelMatchRequest; res: ReversiCancelMatchResponse };
+	'reversi/games': { req: ReversiGamesRequest; res: ReversiGamesResponse };
+	'reversi/match': { req: ReversiMatchRequest; res: ReversiMatchResponse };
+	'reversi/invitations': { req: EmptyRequest; res: ReversiInvitationsResponse };
+	'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse };
+	'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse };
 }
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index dfe24ce0d8af0c006fc33d9ba1bdea986fe4dae6..c14876c0e3771fa5e2f12b59ae79387e49b1f994 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-13T04:31:38.775Z
+ * generatedAt: 2024-01-19T11:00:07.156Z
  */
 
 import { operations } from './types.js';
@@ -546,3 +546,13 @@ export type BubbleGameRegisterRequest = operations['bubble-game/register']['requ
 export type BubbleGameRegisterResponse = operations['bubble-game/register']['responses']['200']['content']['application/json'];
 export type BubbleGameRankingRequest = operations['bubble-game/ranking']['requestBody']['content']['application/json'];
 export type BubbleGameRankingResponse = operations['bubble-game/ranking']['responses']['200']['content']['application/json'];
+export type ReversiCancelMatchRequest = operations['reversi/cancel-match']['requestBody']['content']['application/json'];
+export type ReversiCancelMatchResponse = operations['reversi/cancel-match']['responses']['200']['content']['application/json'];
+export type ReversiGamesRequest = operations['reversi/games']['requestBody']['content']['application/json'];
+export type ReversiGamesResponse = operations['reversi/games']['responses']['200']['content']['application/json'];
+export type ReversiMatchRequest = operations['reversi/match']['requestBody']['content']['application/json'];
+export type ReversiMatchResponse = operations['reversi/match']['responses']['200']['content']['application/json'];
+export type ReversiInvitationsResponse = operations['reversi/invitations']['responses']['200']['content']['application/json'];
+export type ReversiShowGameRequest = operations['reversi/show-game']['requestBody']['content']['application/json'];
+export type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200']['content']['application/json'];
+export type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 5c6bebf2fd0253d4b8f2190756f0bd42416d5900..78f14d2250c11375a15800f741db1dc820b4bc1e 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-13T04:31:38.773Z
+ * generatedAt: 2024-01-19T11:00:07.155Z
  */
 
 import { components } from './types.js';
@@ -41,3 +41,5 @@ export type Flash = components['schemas']['Flash'];
 export type Signin = components['schemas']['Signin'];
 export type RoleLite = components['schemas']['RoleLite'];
 export type Role = components['schemas']['Role'];
+export type ReversiGameLite = components['schemas']['ReversiGameLite'];
+export type ReversiGameDetailed = components['schemas']['ReversiGameDetailed'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 76e2b5309c9ff7b62c24259c9525ff92668dcc56..36facf6e28ae5111adbe0ade35fad6e2804894a0 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3,7 +3,7 @@
 
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-13T04:31:38.633Z
+ * generatedAt: 2024-01-19T11:00:07.077Z
  */
 
 /**
@@ -3472,6 +3472,60 @@ export type paths = {
      */
     post: operations['bubble-game/ranking'];
   };
+  '/reversi/cancel-match': {
+    /**
+     * reversi/cancel-match
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:account*
+     */
+    post: operations['reversi/cancel-match'];
+  };
+  '/reversi/games': {
+    /**
+     * reversi/games
+     * @description No description provided.
+     *
+     * **Credential required**: *No*
+     */
+    post: operations['reversi/games'];
+  };
+  '/reversi/match': {
+    /**
+     * reversi/match
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:account*
+     */
+    post: operations['reversi/match'];
+  };
+  '/reversi/invitations': {
+    /**
+     * reversi/invitations
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:account*
+     */
+    post: operations['reversi/invitations'];
+  };
+  '/reversi/show-game': {
+    /**
+     * reversi/show-game
+     * @description No description provided.
+     *
+     * **Credential required**: *No*
+     */
+    post: operations['reversi/show-game'];
+  };
+  '/reversi/surrender': {
+    /**
+     * reversi/surrender
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:account*
+     */
+    post: operations['reversi/surrender'];
+  };
 };
 
 export type webhooks = Record<string, never>;
@@ -4404,6 +4458,72 @@ export type components = {
       };
       usersCount: number;
     });
+    ReversiGameLite: {
+      /** Format: id */
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      /** Format: date-time */
+      startedAt: string | null;
+      isStarted: boolean;
+      isEnded: boolean;
+      form1: Record<string, never> | null;
+      form2: Record<string, never> | null;
+      user1Ready: boolean;
+      user2Ready: boolean;
+      /** Format: id */
+      user1Id: string;
+      /** Format: id */
+      user2Id: string;
+      user1: components['schemas']['User'];
+      user2: components['schemas']['User'];
+      /** Format: id */
+      winnerId: string | null;
+      winner: components['schemas']['User'] | null;
+      /** Format: id */
+      surrendered: string | null;
+      black: number | null;
+      bw: string;
+      isLlotheo: boolean;
+      canPutEverywhere: boolean;
+      loopedBoard: boolean;
+    };
+    ReversiGameDetailed: {
+      /** Format: id */
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      /** Format: date-time */
+      startedAt: string | null;
+      isStarted: boolean;
+      isEnded: boolean;
+      form1: Record<string, never> | null;
+      form2: Record<string, never> | null;
+      user1Ready: boolean;
+      user2Ready: boolean;
+      /** Format: id */
+      user1Id: string;
+      /** Format: id */
+      user2Id: string;
+      user1: components['schemas']['User'];
+      user2: components['schemas']['User'];
+      /** Format: id */
+      winnerId: string | null;
+      winner: components['schemas']['User'] | null;
+      /** Format: id */
+      surrendered: string | null;
+      black: number | null;
+      bw: string;
+      isLlotheo: boolean;
+      canPutEverywhere: boolean;
+      loopedBoard: boolean;
+      logs: {
+          at: number;
+          color: boolean;
+          pos: number;
+        }[];
+      map: string[];
+    };
   };
   responses: never;
   parameters: never;
@@ -25542,5 +25662,325 @@ export type operations = {
       };
     };
   };
+  /**
+   * reversi/cancel-match
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:account*
+   */
+  'reversi/cancel-match': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          userId?: string | null;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * reversi/games
+   * @description No description provided.
+   *
+   * **Credential required**: *No*
+   */
+  'reversi/games': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @default 10 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+          /** @default false */
+          my?: boolean;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ReversiGameLite'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * reversi/match
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:account*
+   */
+  'reversi/match': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          userId?: string | null;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * reversi/invitations
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:account*
+   */
+  'reversi/invitations': {
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['UserLite'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * reversi/show-game
+   * @description No description provided.
+   *
+   * **Credential required**: *No*
+   */
+  'reversi/show-game': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          gameId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ReversiGameDetailed'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * reversi/surrender
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:account*
+   */
+  'reversi/surrender': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          gameId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (without any results) */
+      204: {
+        content: never;
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
 };
 
diff --git a/packages/misskey-reversi/package.json b/packages/misskey-reversi/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..8d3ca30166258b487a3b40a6d7f59c34da2bfa7a
--- /dev/null
+++ b/packages/misskey-reversi/package.json
@@ -0,0 +1,26 @@
+{
+	"name": "misskey-reversi",
+	"version": "0.0.1",
+	"main": "./built/index.js",
+	"types": "./built/index.d.ts",
+	"scripts": {
+		"build": "tsc",
+		"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build\"",
+		"eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
+		"typecheck": "tsc --noEmit",
+		"lint": "pnpm typecheck && pnpm eslint"
+	},
+	"devDependencies": {
+		"@misskey-dev/eslint-plugin": "1.0.0",
+		"@types/node": "20.11.5",
+		"@typescript-eslint/eslint-plugin": "6.19.0",
+		"@typescript-eslint/parser": "6.19.0",
+		"eslint": "8.56.0",
+		"typescript": "5.3.3"
+	},
+	"files": [
+		"built"
+	],
+	"dependencies": {
+	}
+}
diff --git a/packages/misskey-reversi/src/game.ts b/packages/misskey-reversi/src/game.ts
new file mode 100644
index 0000000000000000000000000000000000000000..55d0b84da73521ec57f1f746debe624e7039b2f2
--- /dev/null
+++ b/packages/misskey-reversi/src/game.ts
@@ -0,0 +1,216 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * true ... é»’
+ * false ... 白
+ */
+export type Color = boolean;
+const BLACK = true;
+const WHITE = false;
+
+export type MapCell = 'null' | 'empty';
+
+export type Options = {
+	isLlotheo: boolean;
+	canPutEverywhere: boolean;
+	loopedBoard: boolean;
+};
+
+export type Undo = {
+	color: Color;
+	pos: number;
+
+	/**
+	 * 反転した石の位置の配列
+	 */
+	effects: number[];
+
+	turn: Color | null;
+};
+
+export class Game {
+	public map: MapCell[];
+	public mapWidth: number;
+	public mapHeight: number;
+	public board: (Color | null | undefined)[];
+	public turn: Color | null = BLACK;
+	public opts: Options;
+
+	public prevPos = -1;
+	public prevColor: Color | null = null;
+
+	private logs: Undo[] = [];
+
+	constructor(map: string[], opts: Options) {
+		//#region binds
+		this.put = this.put.bind(this);
+		//#endregion
+
+		//#region Options
+		this.opts = opts;
+		if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
+		if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
+		if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
+		//#endregion
+
+		//#region Parse map data
+		this.mapWidth = map[0].length;
+		this.mapHeight = map.length;
+		const mapData = map.join('');
+
+		this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
+
+		this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
+		//#endregion
+
+		// ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
+		if (!this.canPutSomewhere(BLACK))
+			this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
+	}
+
+	public get blackCount() {
+		return this.board.filter(x => x === BLACK).length;
+	}
+
+	public get whiteCount() {
+		return this.board.filter(x => x === WHITE).length;
+	}
+
+	public posToXy(pos: number): number[] {
+		const x = pos % this.mapWidth;
+		const y = Math.floor(pos / this.mapWidth);
+		return [x, y];
+	}
+
+	public xyToPos(x: number, y: number): number {
+		return x + (y * this.mapWidth);
+	}
+
+	public put(color: Color, pos: number) {
+		this.prevPos = pos;
+		this.prevColor = color;
+
+		this.board[pos] = color;
+
+		// 反転させられる石を取得
+		const effects = this.effects(color, pos);
+
+		// 反転させる
+		for (const pos of effects) {
+			this.board[pos] = color;
+		}
+
+		const turn = this.turn;
+
+		this.logs.push({
+			color,
+			pos,
+			effects,
+			turn
+		});
+
+		this.calcTurn();
+	}
+
+	private calcTurn() {
+		// ターン計算
+		this.turn =
+			this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
+			this.canPutSomewhere(this.prevColor!) ? this.prevColor :
+			null;
+	}
+
+	public undo() {
+		const undo = this.logs.pop()!;
+		this.prevColor = undo.color;
+		this.prevPos = undo.pos;
+		this.board[undo.pos] = null;
+		for (const pos of undo.effects) {
+			const color = this.board[pos];
+			this.board[pos] = !color;
+		}
+		this.turn = undo.turn;
+	}
+
+	public mapDataGet(pos: number): MapCell {
+		const [x, y] = this.posToXy(pos);
+		return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
+	}
+
+	public getPuttablePlaces(color: Color): number[] {
+		return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
+	}
+
+	public canPutSomewhere(color: Color): boolean {
+		return this.getPuttablePlaces(color).length > 0;
+	}
+
+	public canPut(color: Color, pos: number): boolean {
+		return (
+			this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
+			this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
+			this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
+	}
+
+	/**
+	 * 指定のマスに石を置いた時の、反転させられる石を取得します
+	 * @param color 自分の色
+	 * @param initPos 位置
+	 */
+	public effects(color: Color, initPos: number): number[] {
+		const enemyColor = !color;
+
+		const diffVectors: [number, number][] = [
+			[ 0, -1], // 上
+			[+1, -1], // 右上
+			[+1,  0], // 右
+			[+1, +1], // 右下
+			[ 0, +1], // 下
+			[-1, +1], // 左下
+			[-1,  0], // å·¦
+			[-1, -1]  // 左上
+		];
+
+		const effectsInLine = ([dx, dy]: [number, number]): number[] => {
+			const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
+
+			const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
+			let [x, y] = this.posToXy(initPos);
+			while (true) {
+				[x, y] = nextPos(x, y);
+
+				// 座標が指し示す位置がボード外に出たとき
+				if (this.opts.loopedBoard && this.xyToPos(
+					(x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
+					(y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos)
+						// 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
+					return found;
+				else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight)
+					return []; // 挟めないことが確定 (盤面外に到達)
+
+				const pos = this.xyToPos(x, y);
+				if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
+				const stone = this.board[pos];
+				if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
+				if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
+				if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
+			}
+		};
+
+		return ([] as number[]).concat(...diffVectors.map(effectsInLine));
+	}
+
+	public get isEnded(): boolean {
+		return this.turn === null;
+	}
+
+	public get winner(): Color | null {
+		return this.isEnded ?
+			this.blackCount == this.whiteCount ? null :
+			this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
+			undefined as never;
+	}
+}
diff --git a/packages/misskey-reversi/src/index.ts b/packages/misskey-reversi/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..20ed36f2083f74912551d15390030b832c7d287c
--- /dev/null
+++ b/packages/misskey-reversi/src/index.ts
@@ -0,0 +1,7 @@
+import { Game } from './game.js';
+
+export {
+	Game,
+};
+
+export * as maps from './maps.js';
diff --git a/packages/misskey-reversi/src/maps.ts b/packages/misskey-reversi/src/maps.ts
new file mode 100644
index 0000000000000000000000000000000000000000..85cf1a04852b2ee1bb55349b6231aef95bf10936
--- /dev/null
+++ b/packages/misskey-reversi/src/maps.ts
@@ -0,0 +1,715 @@
+/**
+ * 組み込みマップ定義
+ *
+ * データ値:
+ * (スペース) ... マス無し
+ * - ... マス
+ * b ... 初期配置される黒石
+ * w ... 初期配置される白石
+ */
+
+export type Map = {
+	name?: string;
+	category?: string;
+	author?: string;
+	data: string[];
+};
+
+export const fourfour: Map = {
+	name: '4x4',
+	category: '4x4',
+	data: [
+		'----',
+		'-wb-',
+		'-bw-',
+		'----'
+	]
+};
+
+export const sixsix: Map = {
+	name: '6x6',
+	category: '6x6',
+	data: [
+		'------',
+		'------',
+		'--wb--',
+		'--bw--',
+		'------',
+		'------'
+	]
+};
+
+export const roundedSixsix: Map = {
+	name: '6x6 rounded',
+	category: '6x6',
+	author: 'syuilo',
+	data: [
+		' ---- ',
+		'------',
+		'--wb--',
+		'--bw--',
+		'------',
+		' ---- '
+	]
+};
+
+export const roundedSixsix2: Map = {
+	name: '6x6 rounded 2',
+	category: '6x6',
+	author: 'syuilo',
+	data: [
+		'  --  ',
+		' ---- ',
+		'--wb--',
+		'--bw--',
+		' ---- ',
+		'  --  '
+	]
+};
+
+export const eighteight: Map = {
+	name: '8x8',
+	category: '8x8',
+	data: [
+		'--------',
+		'--------',
+		'--------',
+		'---wb---',
+		'---bw---',
+		'--------',
+		'--------',
+		'--------'
+	]
+};
+
+export const eighteightH28: Map = {
+	name: '8x8 handicap 28',
+	category: '8x8',
+	data: [
+		'bbbbbbbb',
+		'b------b',
+		'b------b',
+		'b--wb--b',
+		'b--bw--b',
+		'b------b',
+		'b------b',
+		'bbbbbbbb'
+	]
+};
+
+export const roundedEighteight: Map = {
+	name: '8x8 rounded',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		' ------ ',
+		'--------',
+		'--------',
+		'---wb---',
+		'---bw---',
+		'--------',
+		'--------',
+		' ------ '
+	]
+};
+
+export const roundedEighteight2: Map = {
+	name: '8x8 rounded 2',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'  ----  ',
+		' ------ ',
+		'--------',
+		'---wb---',
+		'---bw---',
+		'--------',
+		' ------ ',
+		'  ----  '
+	]
+};
+
+export const roundedEighteight3: Map = {
+	name: '8x8 rounded 3',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'   --   ',
+		'  ----  ',
+		' ------ ',
+		'---wb---',
+		'---bw---',
+		' ------ ',
+		'  ----  ',
+		'   --   '
+	]
+};
+
+export const eighteightWithNotch: Map = {
+	name: '8x8 with notch',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'---  ---',
+		'--------',
+		'--------',
+		' --wb-- ',
+		' --bw-- ',
+		'--------',
+		'--------',
+		'---  ---'
+	]
+};
+
+export const eighteightWithSomeHoles: Map = {
+	name: '8x8 with some holes',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'--- ----',
+		'----- --',
+		'-- -----',
+		'---wb---',
+		'---bw- -',
+		' -------',
+		'--- ----',
+		'--------'
+	]
+};
+
+export const circle: Map = {
+	name: 'Circle',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'   --   ',
+		' ------ ',
+		' ------ ',
+		'---wb---',
+		'---bw---',
+		' ------ ',
+		' ------ ',
+		'   --   '
+	]
+};
+
+export const smile: Map = {
+	name: 'Smile',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		' ------ ',
+		'--------',
+		'-- -- --',
+		'---wb---',
+		'-- bw --',
+		'---  ---',
+		'--------',
+		' ------ '
+	]
+};
+
+export const window: Map = {
+	name: 'Window',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'--------',
+		'-  --  -',
+		'-  --  -',
+		'---wb---',
+		'---bw---',
+		'-  --  -',
+		'-  --  -',
+		'--------'
+	]
+};
+
+export const reserved: Map = {
+	name: 'Reserved',
+	category: '8x8',
+	author: 'Aya',
+	data: [
+		'w------b',
+		'--------',
+		'--------',
+		'---wb---',
+		'---bw---',
+		'--------',
+		'--------',
+		'b------w'
+	]
+};
+
+export const x: Map = {
+	name: 'X',
+	category: '8x8',
+	author: 'Aya',
+	data: [
+		'w------b',
+		'-w----b-',
+		'--w--b--',
+		'---wb---',
+		'---bw---',
+		'--b--w--',
+		'-b----w-',
+		'b------w'
+	]
+};
+
+export const parallel: Map = {
+	name: 'Parallel',
+	category: '8x8',
+	author: 'Aya',
+	data: [
+		'--------',
+		'--------',
+		'--------',
+		'---bb---',
+		'---ww---',
+		'--------',
+		'--------',
+		'--------'
+	]
+};
+
+export const lackOfBlack: Map = {
+	name: 'Lack of Black',
+	category: '8x8',
+	data: [
+		'--------',
+		'--------',
+		'--------',
+		'---w----',
+		'---bw---',
+		'--------',
+		'--------',
+		'--------'
+	]
+};
+
+export const squareParty: Map = {
+	name: 'Square Party',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'--------',
+		'-wwwbbb-',
+		'-w-wb-b-',
+		'-wwwbbb-',
+		'-bbbwww-',
+		'-b-bw-w-',
+		'-bbbwww-',
+		'--------'
+	]
+};
+
+export const minesweeper: Map = {
+	name: 'Minesweeper',
+	category: '8x8',
+	author: 'syuilo',
+	data: [
+		'b-b--w-w',
+		'-w-wb-b-',
+		'w-b--w-b',
+		'-b-wb-w-',
+		'-w-bw-b-',
+		'b-w--b-w',
+		'-b-bw-w-',
+		'w-w--b-b'
+	]
+};
+
+export const tenthtenth: Map = {
+	name: '10x10',
+	category: '10x10',
+	data: [
+		'----------',
+		'----------',
+		'----------',
+		'----------',
+		'----wb----',
+		'----bw----',
+		'----------',
+		'----------',
+		'----------',
+		'----------'
+	]
+};
+
+export const hole: Map = {
+	name: 'The Hole',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'----------',
+		'----------',
+		'--wb--wb--',
+		'--bw--bw--',
+		'----  ----',
+		'----  ----',
+		'--wb--wb--',
+		'--bw--bw--',
+		'----------',
+		'----------'
+	]
+};
+
+export const grid: Map = {
+	name: 'Grid',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'----------',
+		'- - -- - -',
+		'----------',
+		'- - -- - -',
+		'----wb----',
+		'----bw----',
+		'- - -- - -',
+		'----------',
+		'- - -- - -',
+		'----------'
+	]
+};
+
+export const cross: Map = {
+	name: 'Cross',
+	category: '10x10',
+	author: 'Aya',
+	data: [
+		'   ----   ',
+		'   ----   ',
+		'   ----   ',
+		'----------',
+		'----wb----',
+		'----bw----',
+		'----------',
+		'   ----   ',
+		'   ----   ',
+		'   ----   '
+	]
+};
+
+export const charX: Map = {
+	name: 'Char X',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'---    ---',
+		'----  ----',
+		'----------',
+		' -------- ',
+		'  --wb--  ',
+		'  --bw--  ',
+		' -------- ',
+		'----------',
+		'----  ----',
+		'---    ---'
+	]
+};
+
+export const charY: Map = {
+	name: 'Char Y',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'---    ---',
+		'----  ----',
+		'----------',
+		' -------- ',
+		'  --wb--  ',
+		'  --bw--  ',
+		'  ------  ',
+		'  ------  ',
+		'  ------  ',
+		'  ------  '
+	]
+};
+
+export const walls: Map = {
+	name: 'Walls',
+	category: '10x10',
+	author: 'Aya',
+	data: [
+		' bbbbbbbb ',
+		'w--------w',
+		'w--------w',
+		'w--------w',
+		'w---wb---w',
+		'w---bw---w',
+		'w--------w',
+		'w--------w',
+		'w--------w',
+		' bbbbbbbb '
+	]
+};
+
+export const cpu: Map = {
+	name: 'CPU',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		' b b  b b ',
+		'w--------w',
+		' -------- ',
+		'w--------w',
+		' ---wb--- ',
+		' ---bw--- ',
+		'w--------w',
+		' -------- ',
+		'w--------w',
+		' b b  b b '
+	]
+};
+
+export const checker: Map = {
+	name: 'Checker',
+	category: '10x10',
+	author: 'Aya',
+	data: [
+		'----------',
+		'----------',
+		'----------',
+		'---wbwb---',
+		'---bwbw---',
+		'---wbwb---',
+		'---bwbw---',
+		'----------',
+		'----------',
+		'----------'
+	]
+};
+
+export const japaneseCurry: Map = {
+	name: 'Japanese curry',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'w-b-b-b-b-',
+		'-w-b-b-b-b',
+		'w-w-b-b-b-',
+		'-w-w-b-b-b',
+		'w-w-wwb-b-',
+		'-w-wbb-b-b',
+		'w-w-w-b-b-',
+		'-w-w-w-b-b',
+		'w-w-w-w-b-',
+		'-w-w-w-w-b'
+	]
+};
+
+export const mosaic: Map = {
+	name: 'Mosaic',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'- - - - - ',
+		' - - - - -',
+		'- - - - - ',
+		' - w w - -',
+		'- - b b - ',
+		' - w w - -',
+		'- - b b - ',
+		' - - - - -',
+		'- - - - - ',
+		' - - - - -',
+	]
+};
+
+export const arena: Map = {
+	name: 'Arena',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'- - -- - -',
+		' - -  - - ',
+		'- ------ -',
+		' -------- ',
+		'- --wb-- -',
+		'- --bw-- -',
+		' -------- ',
+		'- ------ -',
+		' - -  - - ',
+		'- - -- - -'
+	]
+};
+
+export const reactor: Map = {
+	name: 'Reactor',
+	category: '10x10',
+	author: 'syuilo',
+	data: [
+		'-w------b-',
+		'b- -  - -w',
+		'- --wb-- -',
+		'---b  w---',
+		'- b wb w -',
+		'- w bw b -',
+		'---w  b---',
+		'- --bw-- -',
+		'w- -  - -b',
+		'-b------w-'
+	]
+};
+
+export const sixeight: Map = {
+	name: '6x8',
+	category: 'Special',
+	data: [
+		'------',
+		'------',
+		'------',
+		'--wb--',
+		'--bw--',
+		'------',
+		'------',
+		'------'
+	]
+};
+
+export const spark: Map = {
+	name: 'Spark',
+	category: 'Special',
+	author: 'syuilo',
+	data: [
+		' -      - ',
+		'----------',
+		' -------- ',
+		' -------- ',
+		' ---wb--- ',
+		' ---bw--- ',
+		' -------- ',
+		' -------- ',
+		'----------',
+		' -      - '
+	]
+};
+
+export const islands: Map = {
+	name: 'Islands',
+	category: 'Special',
+	author: 'syuilo',
+	data: [
+		'--------  ',
+		'---wb---  ',
+		'---bw---  ',
+		'--------  ',
+		'  -    -  ',
+		'  -    -  ',
+		'  --------',
+		'  --------',
+		'  --------',
+		'  --------'
+	]
+};
+
+export const galaxy: Map = {
+	name: 'Galaxy',
+	category: 'Special',
+	author: 'syuilo',
+	data: [
+		'   ------   ',
+		'  --www---  ',
+		' ------w--- ',
+		'---bbb--w---',
+		'--b---b-w-b-',
+		'-b--wwb-w-b-',
+		'-b-w-bww--b-',
+		'-b-w-b---b--',
+		'---w--bbb---',
+		' ---w------ ',
+		'  ---www--  ',
+		'   ------   '
+	]
+};
+
+export const triangle: Map = {
+	name: 'Triangle',
+	category: 'Special',
+	author: 'syuilo',
+	data: [
+		'    --    ',
+		'    --    ',
+		'   ----   ',
+		'   ----   ',
+		'  --wb--  ',
+		'  --bw--  ',
+		' -------- ',
+		' -------- ',
+		'----------',
+		'----------'
+	]
+};
+
+export const iphonex: Map = {
+	name: 'iPhone X',
+	category: 'Special',
+	author: 'syuilo',
+	data: [
+		' --  -- ',
+		'--------',
+		'--------',
+		'--------',
+		'--------',
+		'---wb---',
+		'---bw---',
+		'--------',
+		'--------',
+		'--------',
+		'--------',
+		' ------ '
+	]
+};
+
+export const dealWithIt: Map = {
+	name: 'Deal with it!',
+	category: 'Special',
+	author: 'syuilo',
+	data: [
+		'------------',
+		'--w-b-------',
+		' --b-w------',
+		'  --w-b---- ',
+		'   -------  '
+	]
+};
+
+export const bigBoard: Map = {
+	name: 'Big board',
+	category: 'Special',
+	data: [
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'-------wb-------',
+		'-------bw-------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------',
+		'----------------'
+	]
+};
+
+export const twoBoard: Map = {
+	name: 'Two board',
+	category: 'Special',
+	author: 'Aya',
+	data: [
+		'-------- --------',
+		'-------- --------',
+		'-------- --------',
+		'---wb--- ---wb---',
+		'---bw--- ---bw---',
+		'-------- --------',
+		'-------- --------',
+		'-------- --------'
+	]
+};
diff --git a/packages/misskey-reversi/tsconfig.json b/packages/misskey-reversi/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..f56b65e86802158634e10482fe7a47eee39cf5cf
--- /dev/null
+++ b/packages/misskey-reversi/tsconfig.json
@@ -0,0 +1,33 @@
+{
+	"$schema": "https://json.schemastore.org/tsconfig",
+	"compilerOptions": {
+		"target": "ES2022",
+		"module": "nodenext",
+		"moduleResolution": "nodenext",
+		"declaration": true,
+		"declarationMap": true,
+		"sourceMap": true,
+		"outDir": "./built/",
+		"removeComments": true,
+		"strict": true,
+		"strictFunctionTypes": true,
+		"strictNullChecks": true,
+		"experimentalDecorators": true,
+		"noImplicitReturns": true,
+		"esModuleInterop": true,
+		"typeRoots": [
+			"./node_modules/@types"
+		],
+		"lib": [
+			"esnext",
+			"dom"
+		]
+	},
+	"include": [
+		"src/**/*"
+	],
+	"exclude": [
+		"node_modules",
+		"test/**/*"
+	]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 825a7ab860bbe6e192215edb46402de93fdce97c..31394eb0815d07bddfa3e425fdfd37e562b9c68a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -185,6 +185,9 @@ importers:
       content-disposition:
         specifier: 0.5.4
         version: 0.5.4
+      crc-32:
+        specifier: ^1.2.2
+        version: 1.2.2
       date-fns:
         specifier: 2.30.0
         version: 2.30.0
@@ -263,6 +266,9 @@ importers:
       misskey-js:
         specifier: workspace:*
         version: link:../misskey-js
+      misskey-reversi:
+        specifier: workspace:*
+        version: link:../misskey-reversi
       ms:
         specifier: 3.0.0-canary.1
         version: 3.0.0-canary.1
@@ -736,6 +742,9 @@ importers:
       compare-versions:
         specifier: 6.1.0
         version: 6.1.0
+      crc-32:
+        specifier: ^1.2.2
+        version: 1.2.2
       cropperjs:
         specifier: 2.0.0-beta.4
         version: 2.0.0-beta.4
@@ -772,6 +781,9 @@ importers:
       misskey-js:
         specifier: workspace:*
         version: link:../misskey-js
+      misskey-reversi:
+        specifier: workspace:*
+        version: link:../misskey-reversi
       photoswipe:
         specifier: 5.4.3
         version: 5.4.3
@@ -1114,6 +1126,27 @@ importers:
         specifier: 5.3.3
         version: 5.3.3
 
+  packages/misskey-reversi:
+    devDependencies:
+      '@misskey-dev/eslint-plugin':
+        specifier: 1.0.0
+        version: 1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.19.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
+      '@types/node':
+        specifier: 20.11.5
+        version: 20.11.5
+      '@typescript-eslint/eslint-plugin':
+        specifier: 6.19.0
+        version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/parser':
+        specifier: 6.19.0
+        version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      eslint:
+        specifier: 8.56.0
+        version: 8.56.0
+      typescript:
+        specifier: 5.3.3
+        version: 5.3.3
+
   packages/sw:
     dependencies:
       esbuild:
@@ -1128,7 +1161,7 @@ importers:
     devDependencies:
       '@misskey-dev/eslint-plugin':
         specifier: ^1.0.0
-        version: 1.0.0(@typescript-eslint/eslint-plugin@6.14.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
+        version: 1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
       '@typescript-eslint/parser':
         specifier: 6.14.0
         version: 6.14.0(eslint@8.56.0)(typescript@5.3.3)
@@ -1812,7 +1845,7 @@ packages:
       '@babel/traverse': 7.22.11
       '@babel/types': 7.22.17
       convert-source-map: 1.9.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -1835,7 +1868,7 @@ packages:
       '@babel/traverse': 7.23.5
       '@babel/types': 7.23.5
       convert-source-map: 2.0.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -1937,7 +1970,7 @@ packages:
       '@babel/core': 7.23.5
       '@babel/helper-compilation-targets': 7.22.15
       '@babel/helper-plugin-utils': 7.22.5
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -3336,7 +3369,7 @@ packages:
       '@babel/helper-split-export-declaration': 7.22.6
       '@babel/parser': 7.23.5
       '@babel/types': 7.22.17
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -3354,7 +3387,7 @@ packages:
       '@babel/helper-split-export-declaration': 7.22.6
       '@babel/parser': 7.23.5
       '@babel/types': 7.23.5
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -4233,7 +4266,7 @@ packages:
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
     dependencies:
       ajv: 6.12.6
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       espree: 9.6.1
       globals: 13.19.0
       ignore: 5.2.4
@@ -4250,7 +4283,7 @@ packages:
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
     dependencies:
       ajv: 6.12.6
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       espree: 9.6.1
       globals: 13.19.0
       ignore: 5.2.4
@@ -4515,7 +4548,7 @@ packages:
     engines: {node: '>=10.10.0'}
     dependencies:
       '@humanwhocodes/object-schema': 2.0.1
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       minimatch: 3.1.2
     transitivePeerDependencies:
       - supports-color
@@ -4571,7 +4604,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       chalk: 4.1.2
       jest-message-util: 29.7.0
       jest-util: 29.7.0
@@ -4592,14 +4625,14 @@ packages:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       ansi-escapes: 4.3.2
       chalk: 4.1.2
       ci-info: 3.7.1
       exit: 0.1.2
       graceful-fs: 4.2.11
       jest-changed-files: 29.7.0
-      jest-config: 29.7.0(@types/node@20.10.5)
+      jest-config: 29.7.0(@types/node@20.11.5)
       jest-haste-map: 29.7.0
       jest-message-util: 29.7.0
       jest-regex-util: 29.6.3
@@ -4634,7 +4667,7 @@ packages:
     dependencies:
       '@jest/fake-timers': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       jest-mock: 29.7.0
     dev: true
 
@@ -4661,7 +4694,7 @@ packages:
     dependencies:
       '@jest/types': 29.6.3
       '@sinonjs/fake-timers': 10.3.0
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       jest-message-util: 29.7.0
       jest-mock: 29.7.0
       jest-util: 29.7.0
@@ -4694,7 +4727,7 @@ packages:
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
       '@jridgewell/trace-mapping': 0.3.18
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       chalk: 4.1.2
       collect-v8-coverage: 1.0.1
       exit: 0.1.2
@@ -4788,7 +4821,7 @@ packages:
     dependencies:
       '@types/istanbul-lib-coverage': 2.0.4
       '@types/istanbul-reports': 3.0.1
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       '@types/yargs': 16.0.5
       chalk: 4.1.2
     dev: true
@@ -4800,7 +4833,7 @@ packages:
       '@jest/schemas': 29.6.3
       '@types/istanbul-lib-coverage': 2.0.4
       '@types/istanbul-reports': 3.0.1
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       '@types/yargs': 17.0.19
       chalk: 4.1.2
     dev: true
@@ -4992,6 +5025,34 @@ packages:
       eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)
     dev: true
 
+  /@misskey-dev/eslint-plugin@1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0):
+    resolution: {integrity: sha512-dh6UbcrNDVg5DD8k8Qh4ab30OPpuEYIlJCqaBV/lkIV8wNN/AfCJ2V7iTP8V8KjryM4t+sf5IqzQLQnT0mWI4A==}
+    peerDependencies:
+      '@typescript-eslint/eslint-plugin': '>= 6'
+      '@typescript-eslint/parser': '>= 6'
+      eslint: '>= 3'
+      eslint-plugin-import: '>= 2'
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 6.19.0(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/parser': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
+      eslint: 8.56.0
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)
+    dev: true
+
+  /@misskey-dev/eslint-plugin@1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.19.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0):
+    resolution: {integrity: sha512-dh6UbcrNDVg5DD8k8Qh4ab30OPpuEYIlJCqaBV/lkIV8wNN/AfCJ2V7iTP8V8KjryM4t+sf5IqzQLQnT0mWI4A==}
+    peerDependencies:
+      '@typescript-eslint/eslint-plugin': '>= 6'
+      '@typescript-eslint/parser': '>= 6'
+      eslint: '>= 3'
+      eslint-plugin-import: '>= 2'
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      eslint: 8.56.0
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)
+    dev: true
+
   /@misskey-dev/sharp-read-bmp@1.1.1:
     resolution: {integrity: sha512-X52BQYL/I9mafypQ+wBhst+BUlYiPWnHhKGcF6ybcYSLl+zhcV0q5mezIXHozhM0Sv0A7xCdrWmR7TCNxHLrtQ==}
     dependencies:
@@ -5089,7 +5150,7 @@ packages:
       '@open-draft/until': 1.0.3
       '@types/debug': 4.1.7
       '@xmldom/xmldom': 0.8.6
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       headers-polyfill: 3.2.5
       outvariant: 1.4.0
       strict-event-emitter: 0.2.8
@@ -7992,7 +8053,7 @@ packages:
     dependencies:
       '@types/http-cache-semantics': 4.0.1
       '@types/keyv': 3.1.4
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       '@types/responselike': 1.0.0
     dev: false
 
@@ -8025,7 +8086,7 @@ packages:
   /@types/connect@3.4.35:
     resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/content-disposition@0.5.8:
@@ -8039,7 +8100,7 @@ packages:
   /@types/cross-spawn@6.0.2:
     resolution: {integrity: sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/debug@4.1.7:
@@ -8097,7 +8158,7 @@ packages:
   /@types/express-serve-static-core@4.17.33:
     resolution: {integrity: sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       '@types/qs': 6.9.7
       '@types/range-parser': 1.2.4
     dev: true
@@ -8125,13 +8186,13 @@ packages:
     resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==}
     dependencies:
       '@types/minimatch': 5.1.2
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/graceful-fs@4.1.6:
     resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/http-cache-semantics@4.0.1:
@@ -8212,7 +8273,7 @@ packages:
   /@types/keyv@3.1.4:
     resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: false
 
   /@types/lodash@4.14.191:
@@ -8261,7 +8322,7 @@ packages:
   /@types/node-fetch@2.6.4:
     resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       form-data: 3.0.1
 
   /@types/node-fetch@3.0.3:
@@ -8279,6 +8340,11 @@ packages:
     dependencies:
       undici-types: 5.26.5
 
+  /@types/node@20.11.5:
+    resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==}
+    dependencies:
+      undici-types: 5.26.5
+
   /@types/node@20.9.1:
     resolution: {integrity: sha512-HhmzZh5LSJNS5O8jQKpJ/3ZcrrlG6L70hpGqMIAoM9YVD0YBRNWYsfwcXq8VnSjlNpCpgLzMXdiPo+dxcvSmiA==}
     dependencies:
@@ -8381,7 +8447,7 @@ packages:
   /@types/readdir-glob@1.1.1:
     resolution: {integrity: sha512-ImM6TmoF8bgOwvehGviEj3tRdRBbQujr1N+0ypaln/GWjaerOB26jb93vsRHmdMtvVQZQebOlqt2HROark87mQ==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/rename@1.0.7:
@@ -8395,7 +8461,7 @@ packages:
   /@types/responselike@1.0.0:
     resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: false
 
   /@types/sanitize-html@2.9.5:
@@ -8421,7 +8487,7 @@ packages:
     resolution: {integrity: sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==}
     dependencies:
       '@types/mime': 3.0.1
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/serviceworker@0.0.67:
@@ -8431,7 +8497,7 @@ packages:
   /@types/set-cookie-parser@2.4.3:
     resolution: {integrity: sha512-7QhnH7bi+6KAhBB+Auejz1uV9DHiopZqu7LfR/5gZZTkejJV5nYeZZpgfFoE0N8aDsXuiYpfKyfyMatCwQhyTQ==}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /@types/sharp@0.32.0:
@@ -8534,7 +8600,7 @@ packages:
     resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
     requiresBuild: true
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
     optional: true
 
@@ -8555,7 +8621,7 @@ packages:
       '@typescript-eslint/type-utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3)
       '@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3)
       '@typescript-eslint/visitor-keys': 6.11.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.53.0
       graphemer: 1.4.0
       ignore: 5.2.4
@@ -8584,7 +8650,65 @@ packages:
       '@typescript-eslint/type-utils': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
       '@typescript-eslint/utils': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
       '@typescript-eslint/visitor-keys': 6.14.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
+      eslint: 8.56.0
+      graphemer: 1.4.0
+      ignore: 5.2.4
+      natural-compare: 1.4.0
+      semver: 7.5.4
+      ts-api-utils: 1.0.1(typescript@5.3.3)
+      typescript: 5.3.3
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)(typescript@5.3.3):
+    resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    peerDependencies:
+      '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
+      eslint: ^7.0.0 || ^8.0.0
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+    dependencies:
+      '@eslint-community/regexpp': 4.6.2
+      '@typescript-eslint/parser': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/scope-manager': 6.19.0
+      '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/visitor-keys': 6.19.0
+      debug: 4.3.4(supports-color@5.5.0)
+      eslint: 8.56.0
+      graphemer: 1.4.0
+      ignore: 5.2.4
+      natural-compare: 1.4.0
+      semver: 7.5.4
+      ts-api-utils: 1.0.1(typescript@5.3.3)
+      typescript: 5.3.3
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3):
+    resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    peerDependencies:
+      '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
+      eslint: ^7.0.0 || ^8.0.0
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+    dependencies:
+      '@eslint-community/regexpp': 4.6.2
+      '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/scope-manager': 6.19.0
+      '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      '@typescript-eslint/visitor-keys': 6.19.0
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.56.0
       graphemer: 1.4.0
       ignore: 5.2.4
@@ -8610,7 +8734,7 @@ packages:
       '@typescript-eslint/types': 6.11.0
       '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3)
       '@typescript-eslint/visitor-keys': 6.11.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.53.0
       typescript: 5.3.3
     transitivePeerDependencies:
@@ -8631,7 +8755,28 @@ packages:
       '@typescript-eslint/types': 6.14.0
       '@typescript-eslint/typescript-estree': 6.14.0(typescript@5.3.3)
       '@typescript-eslint/visitor-keys': 6.14.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
+      eslint: 8.56.0
+      typescript: 5.3.3
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+    resolution: {integrity: sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    peerDependencies:
+      eslint: ^7.0.0 || ^8.0.0
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+    dependencies:
+      '@typescript-eslint/scope-manager': 6.19.0
+      '@typescript-eslint/types': 6.19.0
+      '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+      '@typescript-eslint/visitor-keys': 6.19.0
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.56.0
       typescript: 5.3.3
     transitivePeerDependencies:
@@ -8654,6 +8799,14 @@ packages:
       '@typescript-eslint/visitor-keys': 6.14.0
     dev: true
 
+  /@typescript-eslint/scope-manager@6.19.0:
+    resolution: {integrity: sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    dependencies:
+      '@typescript-eslint/types': 6.19.0
+      '@typescript-eslint/visitor-keys': 6.19.0
+    dev: true
+
   /@typescript-eslint/type-utils@6.11.0(eslint@8.53.0)(typescript@5.3.3):
     resolution: {integrity: sha512-nA4IOXwZtqBjIoYrJcYxLRO+F9ri+leVGoJcMW1uqr4r1Hq7vW5cyWrA43lFbpRvQ9XgNrnfLpIkO3i1emDBIA==}
     engines: {node: ^16.0.0 || >=18.0.0}
@@ -8666,7 +8819,7 @@ packages:
     dependencies:
       '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3)
       '@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3)
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.53.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
       typescript: 5.3.3
@@ -8686,7 +8839,27 @@ packages:
     dependencies:
       '@typescript-eslint/typescript-estree': 6.14.0(typescript@5.3.3)
       '@typescript-eslint/utils': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
+      eslint: 8.56.0
+      ts-api-utils: 1.0.1(typescript@5.3.3)
+      typescript: 5.3.3
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@typescript-eslint/type-utils@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+    resolution: {integrity: sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    peerDependencies:
+      eslint: ^7.0.0 || ^8.0.0
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+    dependencies:
+      '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+      '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.56.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
       typescript: 5.3.3
@@ -8704,6 +8877,11 @@ packages:
     engines: {node: ^16.0.0 || >=18.0.0}
     dev: true
 
+  /@typescript-eslint/types@6.19.0:
+    resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    dev: true
+
   /@typescript-eslint/typescript-estree@6.11.0(typescript@5.3.3):
     resolution: {integrity: sha512-Aezzv1o2tWJwvZhedzvD5Yv7+Lpu1by/U1LZ5gLc4tCx8jUmuSCMioPFRjliN/6SJIvY6HpTtJIWubKuYYYesQ==}
     engines: {node: ^16.0.0 || >=18.0.0}
@@ -8715,7 +8893,7 @@ packages:
     dependencies:
       '@typescript-eslint/types': 6.11.0
       '@typescript-eslint/visitor-keys': 6.11.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       globby: 11.1.0
       is-glob: 4.0.3
       semver: 7.5.4
@@ -8736,7 +8914,7 @@ packages:
     dependencies:
       '@typescript-eslint/types': 6.14.0
       '@typescript-eslint/visitor-keys': 6.14.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       globby: 11.1.0
       is-glob: 4.0.3
       semver: 7.5.4
@@ -8746,6 +8924,28 @@ packages:
       - supports-color
     dev: true
 
+  /@typescript-eslint/typescript-estree@6.19.0(typescript@5.3.3):
+    resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+    dependencies:
+      '@typescript-eslint/types': 6.19.0
+      '@typescript-eslint/visitor-keys': 6.19.0
+      debug: 4.3.4(supports-color@5.5.0)
+      globby: 11.1.0
+      is-glob: 4.0.3
+      minimatch: 9.0.3
+      semver: 7.5.4
+      ts-api-utils: 1.0.1(typescript@5.3.3)
+      typescript: 5.3.3
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /@typescript-eslint/utils@6.11.0(eslint@8.53.0)(typescript@5.3.3):
     resolution: {integrity: sha512-p23ibf68fxoZy605dc0dQAEoUsoiNoP3MD9WQGiHLDuTSOuqoTsa4oAy+h3KDkTcxbbfOtUjb9h3Ta0gT4ug2g==}
     engines: {node: ^16.0.0 || >=18.0.0}
@@ -8784,6 +8984,25 @@ packages:
       - typescript
     dev: true
 
+  /@typescript-eslint/utils@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+    resolution: {integrity: sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    peerDependencies:
+      eslint: ^7.0.0 || ^8.0.0
+    dependencies:
+      '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0)
+      '@types/json-schema': 7.0.12
+      '@types/semver': 7.5.6
+      '@typescript-eslint/scope-manager': 6.19.0
+      '@typescript-eslint/types': 6.19.0
+      '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+      eslint: 8.56.0
+      semver: 7.5.4
+    transitivePeerDependencies:
+      - supports-color
+      - typescript
+    dev: true
+
   /@typescript-eslint/visitor-keys@6.11.0:
     resolution: {integrity: sha512-+SUN/W7WjBr05uRxPggJPSzyB8zUpaYo2hByKasWbqr3PM8AXfZt8UHdNpBS1v9SA62qnSSMF3380SwDqqprgQ==}
     engines: {node: ^16.0.0 || >=18.0.0}
@@ -8800,6 +9019,14 @@ packages:
       eslint-visitor-keys: 3.4.3
     dev: true
 
+  /@typescript-eslint/visitor-keys@6.19.0:
+    resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==}
+    engines: {node: ^16.0.0 || >=18.0.0}
+    dependencies:
+      '@typescript-eslint/types': 6.19.0
+      eslint-visitor-keys: 3.4.3
+    dev: true
+
   /@ungap/structured-clone@1.2.0:
     resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
     dev: true
@@ -9193,7 +9420,7 @@ packages:
     engines: {node: '>= 6.0.0'}
     requiresBuild: true
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -9201,7 +9428,7 @@ packages:
     resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==}
     engines: {node: '>= 14'}
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -9587,7 +9814,7 @@ packages:
     resolution: {integrity: sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw==}
     dependencies:
       archy: 1.0.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       fastq: 1.15.0
     transitivePeerDependencies:
       - supports-color
@@ -11036,7 +11263,6 @@ packages:
     dependencies:
       ms: 2.1.2
       supports-color: 5.5.0
-    dev: true
 
   /debug@4.3.4(supports-color@8.1.1):
     resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
@@ -11049,6 +11275,7 @@ packages:
     dependencies:
       ms: 2.1.2
       supports-color: 8.1.1
+    dev: true
 
   /decamelize-keys@1.1.1:
     resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
@@ -11265,7 +11492,7 @@ packages:
     hasBin: true
     dependencies:
       address: 1.2.2
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
     dev: true
@@ -11589,7 +11816,7 @@ packages:
     peerDependencies:
       esbuild: '>=0.12 <1'
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       esbuild: 0.18.20
     transitivePeerDependencies:
       - supports-color
@@ -11806,6 +12033,35 @@ packages:
       - supports-color
     dev: true
 
+  /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0):
+    resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==}
+    engines: {node: '>=4'}
+    peerDependencies:
+      '@typescript-eslint/parser': '*'
+      eslint: '*'
+      eslint-import-resolver-node: '*'
+      eslint-import-resolver-typescript: '*'
+      eslint-import-resolver-webpack: '*'
+    peerDependenciesMeta:
+      '@typescript-eslint/parser':
+        optional: true
+      eslint:
+        optional: true
+      eslint-import-resolver-node:
+        optional: true
+      eslint-import-resolver-typescript:
+        optional: true
+      eslint-import-resolver-webpack:
+        optional: true
+    dependencies:
+      '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      debug: 3.2.7(supports-color@8.1.1)
+      eslint: 8.56.0
+      eslint-import-resolver-node: 0.3.9
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.11.0)(eslint@8.53.0):
     resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
     engines: {node: '>=4'}
@@ -11876,6 +12132,41 @@ packages:
       - supports-color
     dev: true
 
+  /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0):
+    resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
+    engines: {node: '>=4'}
+    peerDependencies:
+      '@typescript-eslint/parser': '*'
+      eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
+    peerDependenciesMeta:
+      '@typescript-eslint/parser':
+        optional: true
+    dependencies:
+      '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+      array-includes: 3.1.7
+      array.prototype.findlastindex: 1.2.3
+      array.prototype.flat: 1.3.2
+      array.prototype.flatmap: 1.3.2
+      debug: 3.2.7(supports-color@8.1.1)
+      doctrine: 2.1.0
+      eslint: 8.56.0
+      eslint-import-resolver-node: 0.3.9
+      eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0)
+      hasown: 2.0.0
+      is-core-module: 2.13.1
+      is-glob: 4.0.3
+      minimatch: 3.1.2
+      object.fromentries: 2.0.7
+      object.groupby: 1.0.1
+      object.values: 1.1.7
+      semver: 6.3.1
+      tsconfig-paths: 3.15.0
+    transitivePeerDependencies:
+      - eslint-import-resolver-typescript
+      - eslint-import-resolver-webpack
+      - supports-color
+    dev: true
+
   /eslint-plugin-vue@9.19.2(eslint@8.56.0):
     resolution: {integrity: sha512-CPDqTOG2K4Ni2o4J5wixkLVNwgctKXFu6oBpVJlpNq7f38lh9I80pRTouZSJ2MAebPJlINU/KTFSXyQfBUlymA==}
     engines: {node: ^14.17.0 || >=16.0.0}
@@ -11927,7 +12218,7 @@ packages:
       ajv: 6.12.6
       chalk: 4.1.2
       cross-spawn: 7.0.3
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       doctrine: 3.0.0
       escape-string-regexp: 4.0.0
       eslint-scope: 7.2.2
@@ -11974,7 +12265,7 @@ packages:
       ajv: 6.12.6
       chalk: 4.1.2
       cross-spawn: 7.0.3
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       doctrine: 3.0.0
       escape-string-regexp: 4.0.0
       eslint-scope: 7.2.2
@@ -12604,7 +12895,7 @@ packages:
       debug:
         optional: true
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
 
   /for-each@0.3.3:
     resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -13160,7 +13451,6 @@ packages:
   /has-flag@3.0.0:
     resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
     engines: {node: '>=4'}
-    dev: true
 
   /has-flag@4.0.0:
     resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
@@ -13298,7 +13588,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -13360,7 +13650,7 @@ packages:
     engines: {node: '>= 6.0.0'}
     dependencies:
       agent-base: 5.1.1
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
     dev: true
@@ -13370,7 +13660,7 @@ packages:
     engines: {node: '>= 6'}
     dependencies:
       agent-base: 6.0.2
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -13379,7 +13669,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -13389,7 +13679,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -13549,7 +13839,7 @@ packages:
     dependencies:
       '@ioredis/commands': 1.2.0
       cluster-key-slot: 1.1.2
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       denque: 2.1.0
       lodash.defaults: 4.2.0
       lodash.isarguments: 3.1.0
@@ -13990,7 +14280,7 @@ packages:
     resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
     engines: {node: '>=10'}
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       istanbul-lib-coverage: 3.2.0
       source-map: 0.6.1
     transitivePeerDependencies:
@@ -14044,7 +14334,7 @@ packages:
       '@jest/expect': 29.7.0
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       chalk: 4.1.2
       co: 4.6.0
       dedent: 1.3.0
@@ -14133,6 +14423,46 @@ packages:
       - supports-color
     dev: true
 
+  /jest-config@29.7.0(@types/node@20.11.5):
+    resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    peerDependencies:
+      '@types/node': '*'
+      ts-node: '>=9.0.0'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      ts-node:
+        optional: true
+    dependencies:
+      '@babel/core': 7.22.11
+      '@jest/test-sequencer': 29.7.0
+      '@jest/types': 29.6.3
+      '@types/node': 20.11.5
+      babel-jest: 29.7.0(@babel/core@7.22.11)
+      chalk: 4.1.2
+      ci-info: 3.7.1
+      deepmerge: 4.2.2
+      glob: 7.2.3
+      graceful-fs: 4.2.11
+      jest-circus: 29.7.0
+      jest-environment-node: 29.7.0
+      jest-get-type: 29.6.3
+      jest-regex-util: 29.6.3
+      jest-resolve: 29.7.0
+      jest-runner: 29.7.0
+      jest-util: 29.7.0
+      jest-validate: 29.7.0
+      micromatch: 4.0.5
+      parse-json: 5.2.0
+      pretty-format: 29.7.0
+      slash: 3.0.0
+      strip-json-comments: 3.1.1
+    transitivePeerDependencies:
+      - babel-plugin-macros
+      - supports-color
+    dev: true
+
   /jest-diff@28.1.3:
     resolution: {integrity: sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==}
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
@@ -14188,7 +14518,7 @@ packages:
       '@jest/environment': 29.7.0
       '@jest/fake-timers': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       jest-mock: 29.7.0
       jest-util: 29.7.0
     dev: true
@@ -14218,7 +14548,7 @@ packages:
     dependencies:
       '@jest/types': 29.6.3
       '@types/graceful-fs': 4.1.6
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       anymatch: 3.1.3
       fb-watchman: 2.0.2
       graceful-fs: 4.2.11
@@ -14279,7 +14609,7 @@ packages:
     engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
     dependencies:
       '@jest/types': 27.5.1
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
     dev: true
 
   /jest-mock@29.7.0:
@@ -14342,7 +14672,7 @@ packages:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       chalk: 4.1.2
       emittery: 0.13.1
       graceful-fs: 4.2.11
@@ -14373,7 +14703,7 @@ packages:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       chalk: 4.1.2
       cjs-module-lexer: 1.2.2
       collect-v8-coverage: 1.0.1
@@ -14425,7 +14755,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       chalk: 4.1.2
       ci-info: 3.7.1
       graceful-fs: 4.2.11
@@ -14450,7 +14780,7 @@ packages:
     dependencies:
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       ansi-escapes: 4.3.2
       chalk: 4.1.2
       emittery: 0.13.1
@@ -14469,7 +14799,7 @@ packages:
     resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@types/node': 20.10.5
+      '@types/node': 20.11.5
       jest-util: 29.7.0
       merge-stream: 2.0.0
       supports-color: 8.1.1
@@ -14667,7 +14997,7 @@ packages:
     resolution: {integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==}
     engines: {node: '>=10'}
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       rfdc: 1.3.0
       uri-js: 4.4.1
     transitivePeerDependencies:
@@ -17278,7 +17608,7 @@ packages:
     engines: {node: '>=8.16.0'}
     dependencies:
       '@types/mime-types': 2.1.4
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       extract-zip: 1.7.0
       https-proxy-agent: 4.0.0
       mime: 2.6.0
@@ -18275,7 +18605,7 @@ packages:
     dependencies:
       '@hapi/hoek': 10.0.1
       '@hapi/wreck': 18.0.1
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       joi: 17.7.0
     transitivePeerDependencies:
       - supports-color
@@ -18475,7 +18805,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       socks: 2.7.1
     transitivePeerDependencies:
       - supports-color
@@ -18628,7 +18958,7 @@ packages:
       arg: 5.0.2
       bluebird: 3.7.2
       check-more-types: 2.24.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       execa: 5.1.1
       lazy-ass: 1.6.0
       ps-tree: 1.2.0
@@ -18892,7 +19222,6 @@ packages:
     engines: {node: '>=4'}
     dependencies:
       has-flag: 3.0.0
-    dev: true
 
   /supports-color@7.2.0:
     resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
@@ -19515,7 +19844,7 @@ packages:
       chalk: 4.1.2
       cli-highlight: 2.1.11
       date-fns: 2.30.0
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       dotenv: 16.0.3
       glob: 8.1.0
       ioredis: 5.3.2
@@ -19880,7 +20209,7 @@ packages:
     hasBin: true
     dependencies:
       cac: 6.7.14
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       mlly: 1.4.0
       pathe: 1.1.1
       picocolors: 1.0.0
@@ -19992,7 +20321,7 @@ packages:
       acorn-walk: 8.2.0
       cac: 6.7.14
       chai: 4.3.10
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       happy-dom: 10.0.3
       local-pkg: 0.4.3
       magic-string: 0.30.3
@@ -20074,7 +20403,7 @@ packages:
     peerDependencies:
       eslint: '>=6.0.0'
     dependencies:
-      debug: 4.3.4(supports-color@8.1.1)
+      debug: 4.3.4(supports-color@5.5.0)
       eslint: 8.56.0
       eslint-scope: 7.2.2
       eslint-visitor-keys: 3.4.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3b2ecec7fd6052bcaaf1b416345a35207ca3a924..3a03a582536826d9abddc57a094a9fd9614fce10 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,3 +4,4 @@ packages:
  - 'packages/sw'
  - 'packages/misskey-js'
  - 'packages/misskey-js/generator'
+ - 'packages/misskey-reversi'