From 952386ba8f76a4a3d19f1cc0a29fdf69a552c25e Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 20 Jan 2024 09:53:26 +0900
Subject: [PATCH] refactor: extract bubble-game engine as independent package

---
 packages/frontend/package.json                |   2 +-
 .../src/pages/drop-and-fusion.game.vue        |   2 +-
 packages/frontend/vite.config.ts              |   4 +-
 packages/misskey-bubble-game/.eslintignore    |   7 +
 packages/misskey-bubble-game/.eslintrc.cjs    |   9 +
 packages/misskey-bubble-game/package.json     |  31 ++
 packages/misskey-bubble-game/src/game.ts      | 495 +++++++++++++++++
 packages/misskey-bubble-game/src/index.ts     |  10 +
 .../src/monos.ts}                             | 498 +-----------------
 packages/misskey-bubble-game/tsconfig.json    |  33 ++
 packages/misskey-reversi/.eslintignore        |   7 +
 packages/misskey-reversi/.eslintrc.cjs        |   9 +
 packages/misskey-reversi/src/index.ts         |   5 +
 packages/misskey-reversi/src/maps.ts          |   5 +
 pnpm-lock.yaml                                | 147 ++++--
 pnpm-workspace.yaml                           |   1 +
 16 files changed, 718 insertions(+), 547 deletions(-)
 create mode 100644 packages/misskey-bubble-game/.eslintignore
 create mode 100644 packages/misskey-bubble-game/.eslintrc.cjs
 create mode 100644 packages/misskey-bubble-game/package.json
 create mode 100644 packages/misskey-bubble-game/src/game.ts
 create mode 100644 packages/misskey-bubble-game/src/index.ts
 rename packages/{frontend/src/scripts/drop-and-fusion-engine.ts => misskey-bubble-game/src/monos.ts} (51%)
 create mode 100644 packages/misskey-bubble-game/tsconfig.json
 create mode 100644 packages/misskey-reversi/.eslintignore
 create mode 100644 packages/misskey-reversi/.eslintrc.cjs

diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index a9a68601fc..6dd826d459 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -55,12 +55,12 @@
 		"mfm-js": "0.24.0",
 		"misskey-js": "workspace:*",
 		"misskey-reversi": "workspace:*",
+		"misskey-bubble-game": "workspace:*",
 		"photoswipe": "5.4.3",
 		"punycode": "2.3.1",
 		"rollup": "4.9.1",
 		"sanitize-html": "2.11.0",
 		"sass": "1.69.5",
-		"seedrandom": "^3.0.5",
 		"shiki": "0.14.7",
 		"strict-event-emitter-types": "2.0.0",
 		"textarea-caret": "3.1.0",
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index 1fc0c7cd9c..51819fafd0 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -180,6 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
 import * as Matter from 'matter-js';
 import * as Misskey from 'misskey-js';
+import { DropAndFusionGame, Mono } from 'misskey-bubble-game';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import * as os from '@/os.js';
@@ -193,7 +194,6 @@ import { i18n } from '@/i18n.js';
 import { useInterval } from '@/scripts/use-interval.js';
 import { apiUrl } from '@/config.js';
 import { $i } from '@/account.js';
-import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
 import * as sound from '@/scripts/sound.js';
 import MkRange from '@/components/MkRange.vue';
 import copyToClipboard from '@/scripts/copy-to-clipboard.js';
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 8cdc7b59c6..84fe9e44df 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', 'misskey-reversi'],
+			include: ['misskey-js', 'misskey-reversi', 'misskey-bubble-game'],
 		},
 
 		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/, /misskey-reversi/, /node_modules/],
+				include: [/misskey-js/, /misskey-reversi/, /misskey-bubble-game/, /node_modules/],
 			},
 		},
 
diff --git a/packages/misskey-bubble-game/.eslintignore b/packages/misskey-bubble-game/.eslintignore
new file mode 100644
index 0000000000..f22128f047
--- /dev/null
+++ b/packages/misskey-bubble-game/.eslintignore
@@ -0,0 +1,7 @@
+node_modules
+/built
+/coverage
+/.eslintrc.js
+/jest.config.ts
+/test
+/test-d
diff --git a/packages/misskey-bubble-game/.eslintrc.cjs b/packages/misskey-bubble-game/.eslintrc.cjs
new file mode 100644
index 0000000000..e2e31e9e33
--- /dev/null
+++ b/packages/misskey-bubble-game/.eslintrc.cjs
@@ -0,0 +1,9 @@
+module.exports = {
+	parserOptions: {
+		tsconfigRootDir: __dirname,
+		project: ['./tsconfig.json'],
+	},
+	extends: [
+		'../shared/.eslintrc.js',
+	],
+};
diff --git a/packages/misskey-bubble-game/package.json b/packages/misskey-bubble-game/package.json
new file mode 100644
index 0000000000..806d693670
--- /dev/null
+++ b/packages/misskey-bubble-game/package.json
@@ -0,0 +1,31 @@
+{
+	"name": "misskey-bubble-game",
+	"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/matter-js": "0.19.6",
+		"@types/node": "20.11.5",
+		"@types/seedrandom": "3.0.8",
+		"@typescript-eslint/eslint-plugin": "6.19.0",
+		"@typescript-eslint/parser": "6.19.0",
+		"eslint": "8.56.0",
+		"typescript": "5.3.3"
+	},
+	"files": [
+		"built"
+	],
+	"dependencies": {
+		"eventemitter3": "5.0.1",
+		"matter-js": "0.19.0",
+		"seedrandom": "3.0.5"
+	}
+}
diff --git a/packages/misskey-bubble-game/src/game.ts b/packages/misskey-bubble-game/src/game.ts
new file mode 100644
index 0000000000..e01a011eee
--- /dev/null
+++ b/packages/misskey-bubble-game/src/game.ts
@@ -0,0 +1,495 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import * as Matter from 'matter-js';
+import seedrandom from 'seedrandom';
+import { NORAML_MONOS, SQUARE_MONOS, SWEETS_MONOS, YEN_MONOS } from './monos.js';
+
+export type Mono = {
+	id: string;
+	level: number;
+	sizeX: number;
+	sizeY: number;
+	shape: 'circle' | 'rectangle' | 'custom';
+	vertices?: Matter.Vector[][];
+	verticesSize?: number;
+	score: number;
+	dropCandidate: boolean;
+};
+
+type Log = {
+	frame: number;
+	operation: 'drop';
+	x: number;
+} | {
+	frame: number;
+	operation: 'hold';
+} | {
+	frame: number;
+	operation: 'surrender';
+};
+
+export class DropAndFusionGame extends EventEmitter<{
+	changeScore: (newScore: number) => void;
+	changeCombo: (newCombo: number) => void;
+	changeStock: (newStock: { id: string; mono: Mono }[]) => void;
+	changeHolding: (newHolding: { id: string; mono: Mono } | null) => void;
+	dropped: (x: number) => void;
+	fusioned: (x: number, y: number, nextMono: Mono | null, scoreDelta: number) => void;
+	collision: (energy: number, bodyA: Matter.Body, bodyB: Matter.Body) => void;
+	monoAdded: (mono: Mono) => void;
+	gameOver: () => void;
+}> {
+	private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
+	private COMBO_INTERVAL = 60; // frame
+	public readonly GAME_VERSION = 3;
+	public readonly GAME_WIDTH = 450;
+	public readonly GAME_HEIGHT = 600;
+	public readonly DROP_COOLTIME = 30; // frame
+	public readonly PLAYAREA_MARGIN = 25;
+	private STOCK_MAX = 4;
+	private TICK_DELTA = 1000 / 60; // 60fps
+
+	public frame = 0;
+	public engine: Matter.Engine;
+	private tickCallbackQueue: { frame: number; callback: () => void; }[] = [];
+	private overflowCollider: Matter.Body;
+	private isGameOver = false;
+	private gameMode: 'normal' | 'yen' | 'square' | 'sweets' | 'space';
+	private rng: () => number;
+	private logs: Log[] = [];
+
+	/**
+	 * フィールドに出ていて、かつ合体の対象となるアイテム
+	 */
+	private fusionReadyBodyIds: Matter.Body['id'][] = [];
+
+	private gameOverReadyBodyIds: Matter.Body['id'][] = [];
+
+	/**
+	 * fusion予約アイテムのペア
+	 * TODO: これらのモノは光らせるなどの演出をすると視覚的に楽しそう
+	 */
+	private fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
+
+	private latestDroppedAt = 0; // frame
+	private latestFusionedAt = 0; // frame
+	private stock: { id: string; mono: Mono }[] = [];
+	private holding: { id: string; mono: Mono } | null = null;
+
+	public get monoDefinitions() {
+		switch (this.gameMode) {
+			case 'normal': return NORAML_MONOS;
+			case 'yen': return YEN_MONOS;
+			case 'square': return SQUARE_MONOS;
+			case 'sweets': return SWEETS_MONOS;
+			case 'space': return NORAML_MONOS;
+		}
+	}
+
+	private _combo = 0;
+	private get combo() {
+		return this._combo;
+	}
+	private set combo(value: number) {
+		this._combo = value;
+		this.emit('changeCombo', value);
+	}
+
+	private _score = 0;
+	private get score() {
+		return this._score;
+	}
+	private set score(value: number) {
+		this._score = value;
+		this.emit('changeScore', value);
+	}
+
+	private getMonoRenderOptions: null | ((mono: Mono) => Partial<Matter.IBodyRenderOptions>) = null;
+
+	public replayPlaybackRate = 1;
+
+	constructor(env: {
+		seed: string;
+		gameMode: DropAndFusionGame['gameMode'];
+		getMonoRenderOptions?: (mono: Mono) => Partial<Matter.IBodyRenderOptions>;
+	}) {
+		super();
+
+		//#region BIND
+		this.tick = this.tick.bind(this);
+		//#endregion
+
+		this.gameMode = env.gameMode;
+		this.getMonoRenderOptions = env.getMonoRenderOptions ?? null;
+		this.rng = seedrandom(env.seed);
+
+		// sweetsモードは重いため
+		const physicsQualityFactor = this.gameMode === 'sweets' ? 4 : this.PHYSICS_QUALITY_FACTOR;
+		this.engine = Matter.Engine.create({
+			constraintIterations: 2 * physicsQualityFactor,
+			positionIterations: 6 * physicsQualityFactor,
+			velocityIterations: 4 * physicsQualityFactor,
+			gravity: {
+				x: 0,
+				y: this.gameMode === 'space' ? 0.0125 : 1,
+			},
+			timing: {
+				timeScale: 2,
+			},
+			enableSleeping: false,
+		});
+
+		this.engine.world.bodies = [];
+
+		//#region walls
+		const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
+			label: '_wall_',
+			isStatic: true,
+			friction: 0.7,
+			slop: this.gameMode === 'space' ? 0.01 : 0.7,
+			render: {
+				strokeStyle: 'transparent',
+				fillStyle: 'transparent',
+			},
+		};
+
+		const thickness = 100;
+		Matter.Composite.add(this.engine.world, [
+			Matter.Bodies.rectangle(this.GAME_WIDTH / 2, this.GAME_HEIGHT + (thickness / 2) - this.PLAYAREA_MARGIN, this.GAME_WIDTH, thickness, WALL_OPTIONS),
+			Matter.Bodies.rectangle(this.GAME_WIDTH + (thickness / 2) - this.PLAYAREA_MARGIN, this.GAME_HEIGHT / 2, thickness, this.GAME_HEIGHT, WALL_OPTIONS),
+			Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), this.GAME_HEIGHT / 2, thickness, this.GAME_HEIGHT, WALL_OPTIONS),
+		]);
+		//#endregion
+
+		this.overflowCollider = Matter.Bodies.rectangle(this.GAME_WIDTH / 2, 0, this.GAME_WIDTH, 200, {
+			label: '_overflow_',
+			isStatic: true,
+			isSensor: true,
+			render: {
+				strokeStyle: 'transparent',
+				fillStyle: 'transparent',
+			},
+		});
+		Matter.Composite.add(this.engine.world, this.overflowCollider);
+	}
+
+	public msToFrame(ms: number) {
+		return Math.round(ms / this.TICK_DELTA);
+	}
+
+	public frameToMs(frame: number) {
+		return frame * this.TICK_DELTA;
+	}
+
+	private createBody(mono: Mono, x: number, y: number) {
+		const options: Matter.IBodyDefinition = {
+			label: mono.id,
+			density: this.gameMode === 'space' ? 0.01 : ((mono.sizeX * mono.sizeY) / 10000),
+			restitution: this.gameMode === 'space' ? 0.5 : 0.2,
+			frictionAir: this.gameMode === 'space' ? 0 : 0.01,
+			friction: this.gameMode === 'space' ? 0.5 : 0.7,
+			frictionStatic: this.gameMode === 'space' ? 0 : 5,
+			slop: this.gameMode === 'space' ? 0.01 : 0.7,
+			//mass: 0,
+			render: this.getMonoRenderOptions ? this.getMonoRenderOptions(mono) : undefined,
+		};
+		if (mono.shape === 'circle') {
+			return Matter.Bodies.circle(x, y, mono.sizeX / 2, options);
+		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+		} else if (mono.shape === 'rectangle') {
+			return Matter.Bodies.rectangle(x, y, mono.sizeX, mono.sizeY, options);
+		} else if (mono.shape === 'custom') {
+			return Matter.Bodies.fromVertices(x, y, mono.vertices!.map(i => i.map(j => ({
+				x: (j.x / mono.verticesSize!) * mono.sizeX,
+				y: (j.y / mono.verticesSize!) * mono.sizeY,
+			}))), options);
+		} else {
+			throw new Error('unrecognized shape');
+		}
+	}
+
+	private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
+		if (this.latestFusionedAt > this.frame - this.COMBO_INTERVAL) {
+			this.combo++;
+		} else {
+			this.combo = 1;
+		}
+		this.latestFusionedAt = this.frame;
+
+		const newX = (bodyA.position.x + bodyB.position.x) / 2;
+		const newY = (bodyA.position.y + bodyB.position.y) / 2;
+
+		this.fusionReadyBodyIds = this.fusionReadyBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
+		this.gameOverReadyBodyIds = this.gameOverReadyBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
+		Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
+
+		const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
+		const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1) ?? null;
+
+		if (nextMono) {
+			const body = this.createBody(nextMono, newX, newY);
+			Matter.Composite.add(this.engine.world, body);
+
+			// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
+			this.tickCallbackQueue.push({
+				frame: this.frame + this.msToFrame(100),
+				callback: () => {
+					this.fusionReadyBodyIds.push(body.id);
+				},
+			});
+
+			this.emit('monoAdded', nextMono);
+		}
+
+		const hasComboBonus = this.gameMode !== 'yen' && this.gameMode !== 'sweets';
+		const comboBonus = hasComboBonus ? 1 + ((this.combo - 1) / 5) : 1;
+		const additionalScore = Math.round(currentMono.score * comboBonus);
+		this.score += additionalScore;
+
+		this.emit('fusioned', newX, newY, nextMono, additionalScore);
+	}
+
+	private onCollision(event: Matter.IEventCollision<Matter.Engine>) {
+		for (const pairs of event.pairs) {
+			const { bodyA, bodyB } = pairs;
+
+			const shouldFusion = (bodyA.label === bodyB.label) &&
+				!this.fusionReservedPairs.some(x =>
+					x.bodyA.id === bodyA.id ||
+					x.bodyA.id === bodyB.id ||
+					x.bodyB.id === bodyA.id ||
+					x.bodyB.id === bodyB.id);
+
+			if (shouldFusion) {
+				if (this.fusionReadyBodyIds.includes(bodyA.id) && this.fusionReadyBodyIds.includes(bodyB.id)) {
+					this.fusion(bodyA, bodyB);
+				} else {
+					this.fusionReservedPairs.push({ bodyA, bodyB });
+					this.tickCallbackQueue.push({
+						frame: this.frame + this.msToFrame(100),
+						callback: () => {
+							this.fusionReservedPairs = this.fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
+							this.fusion(bodyA, bodyB);
+						},
+					});
+				}
+			} else {
+				const energy = pairs.collision.depth;
+
+				if (bodyA.label === '_overflow_' || bodyB.label === '_overflow_') continue;
+
+				if (bodyA.label !== '_wall_' && bodyB.label !== '_wall_') {
+					if (!this.gameOverReadyBodyIds.includes(bodyA.id)) this.gameOverReadyBodyIds.push(bodyA.id);
+					if (!this.gameOverReadyBodyIds.includes(bodyB.id)) this.gameOverReadyBodyIds.push(bodyB.id);
+				}
+
+				this.emit('collision', energy, bodyA, bodyB);
+			}
+		}
+	}
+
+	private onCollisionActive(event: Matter.IEventCollision<Matter.Engine>) {
+		for (const pairs of event.pairs) {
+			const { bodyA, bodyB } = pairs;
+
+			// ハコからあふれたかどうかの判定
+			if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) {
+				if (this.gameOverReadyBodyIds.includes(bodyA.id) || this.gameOverReadyBodyIds.includes(bodyB.id)) {
+					this.gameOver();
+					break;
+				}
+				continue;
+			}
+		}
+	}
+
+	public surrender() {
+		this.logs.push({
+			frame: this.frame,
+			operation: 'surrender',
+		});
+
+		this.gameOver();
+	}
+
+	private gameOver() {
+		this.isGameOver = true;
+		this.emit('gameOver');
+	}
+
+	public start() {
+		for (let i = 0; i < this.STOCK_MAX; i++) {
+			this.stock.push({
+				id: this.rng().toString(),
+				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
+			});
+		}
+		this.emit('changeStock', this.stock);
+
+		Matter.Events.on(this.engine, 'collisionStart', this.onCollision.bind(this));
+		Matter.Events.on(this.engine, 'collisionActive', this.onCollisionActive.bind(this));
+	}
+
+	public getLogs() {
+		return this.logs;
+	}
+
+	public tick() {
+		this.frame++;
+
+		if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
+			this.combo = 0;
+		}
+
+		this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
+			if (x.frame === this.frame) {
+				x.callback();
+				return false;
+			} else {
+				return true;
+			}
+		});
+
+		Matter.Engine.update(this.engine, this.TICK_DELTA);
+
+		const hasNextTick = !this.isGameOver;
+
+		return hasNextTick;
+	}
+
+	public getActiveMonos() {
+		return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined);
+	}
+
+	public drop(_x: number) {
+		if (this.isGameOver) return;
+		if (this.frame - this.latestDroppedAt < this.DROP_COOLTIME) return;
+
+		const head = this.stock.shift()!;
+		this.stock.push({
+			id: this.rng().toString(),
+			mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
+		});
+		this.emit('changeStock', this.stock);
+
+		const inputX = Math.round(_x);
+		const x = Math.min(this.GAME_WIDTH - this.PLAYAREA_MARGIN - (head.mono.sizeX / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.sizeX / 2), inputX));
+		const body = this.createBody(head.mono, x, 50 + head.mono.sizeY / 2);
+		this.logs.push({
+			frame: this.frame,
+			operation: 'drop',
+			x: inputX,
+		});
+
+		// add force
+		if (this.gameMode === 'space') {
+			Matter.Body.applyForce(body, body.position, {
+				x: 0,
+				y: (Math.PI * head.mono.sizeX * head.mono.sizeY) / 65536,
+			});
+		}
+
+		Matter.Composite.add(this.engine.world, body);
+
+		this.fusionReadyBodyIds.push(body.id);
+		this.latestDroppedAt = this.frame;
+
+		this.emit('dropped', x);
+		this.emit('monoAdded', head.mono);
+	}
+
+	public hold() {
+		if (this.isGameOver) return;
+
+		this.logs.push({
+			frame: this.frame,
+			operation: 'hold',
+		});
+
+		if (this.holding) {
+			const head = this.stock.shift()!;
+			this.stock.unshift(this.holding);
+			this.holding = head;
+			this.emit('changeHolding', this.holding);
+			this.emit('changeStock', this.stock);
+		} else {
+			const head = this.stock.shift()!;
+			this.holding = head;
+			this.stock.push({
+				id: this.rng().toString(),
+				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
+			});
+			this.emit('changeHolding', this.holding);
+			this.emit('changeStock', this.stock);
+		}
+	}
+
+	public static serializeLogs(logs: Log[]) {
+		const _logs: number[][] = [];
+
+		for (let i = 0; i < logs.length; i++) {
+			const log = logs[i];
+			const frameDelta = i === 0 ? log.frame : log.frame - logs[i - 1].frame;
+
+			switch (log.operation) {
+				case 'drop':
+					_logs.push([frameDelta, 0, log.x]);
+					break;
+				case 'hold':
+					_logs.push([frameDelta, 1]);
+					break;
+				case 'surrender':
+					_logs.push([frameDelta, 2]);
+					break;
+			}
+		}
+
+		return _logs;
+	}
+
+	public static deserializeLogs(logs: number[][]) {
+		const _logs: Log[] = [];
+
+		let frame = 0;
+
+		for (const log of logs) {
+			const frameDelta = log[0];
+			frame += frameDelta;
+
+			const operation = log[1];
+
+			switch (operation) {
+				case 0:
+					_logs.push({
+						frame,
+						operation: 'drop',
+						x: log[2],
+					});
+					break;
+				case 1:
+					_logs.push({
+						frame,
+						operation: 'hold',
+					});
+					break;
+				case 2:
+					_logs.push({
+						frame,
+						operation: 'surrender',
+					});
+					break;
+			}
+		}
+
+		return _logs;
+	}
+
+	public dispose() {
+		Matter.World.clear(this.engine.world, false);
+		Matter.Engine.clear(this.engine);
+	}
+}
diff --git a/packages/misskey-bubble-game/src/index.ts b/packages/misskey-bubble-game/src/index.ts
new file mode 100644
index 0000000000..6df708763f
--- /dev/null
+++ b/packages/misskey-bubble-game/src/index.ts
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { DropAndFusionGame, Mono } from './game.js';
+
+export {
+	DropAndFusionGame, Mono,
+};
diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/misskey-bubble-game/src/monos.ts
similarity index 51%
rename from packages/frontend/src/scripts/drop-and-fusion-engine.ts
rename to packages/misskey-bubble-game/src/monos.ts
index aef2613065..d205c3cba5 100644
--- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts
+++ b/packages/misskey-bubble-game/src/monos.ts
@@ -3,36 +3,10 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { EventEmitter } from 'eventemitter3';
-import * as Matter from 'matter-js';
-import seedrandom from 'seedrandom';
-
-export type Mono = {
-	id: string;
-	level: number;
-	sizeX: number;
-	sizeY: number;
-	shape: 'circle' | 'rectangle' | 'custom';
-	vertices?: Matter.Vector[][];
-	verticesSize?: number;
-	score: number;
-	dropCandidate: boolean;
-};
-
-type Log = {
-	frame: number;
-	operation: 'drop';
-	x: number;
-} | {
-	frame: number;
-	operation: 'hold';
-} | {
-	frame: number;
-	operation: 'surrender';
-};
+import { Mono } from './game.js';
 
 const NORMAL_BASE_SIZE = 32;
-const NORAML_MONOS: Mono[] = [{
+export const NORAML_MONOS: Mono[] = [{
 	id: '9377076d-c980-4d83-bdaf-175bc58275b7',
 	level: 10,
 	sizeX: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
@@ -116,7 +90,7 @@ const NORAML_MONOS: Mono[] = [{
 
 const YEN_BASE_SIZE = 32;
 const YEN_SATSU_BASE_SIZE = 70;
-const YEN_MONOS: Mono[] = [{
+export const YEN_MONOS: Mono[] = [{
 	id: '880f9bd9-802f-4135-a7e1-fd0e0331f726',
 	level: 10,
 	sizeX: (YEN_SATSU_BASE_SIZE * 2) * 1.25 * 1.25 * 1.25,
@@ -199,7 +173,7 @@ const YEN_MONOS: Mono[] = [{
 }];
 
 const SQUARE_BASE_SIZE = 28;
-const SQUARE_MONOS: Mono[] = [{
+export const SQUARE_MONOS: Mono[] = [{
 	id: 'f75fd0ba-d3d4-40a4-9712-b470e45b0525',
 	level: 10,
 	sizeX: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
@@ -282,7 +256,7 @@ const SQUARE_MONOS: Mono[] = [{
 }];
 
 const SWEETS_BASE_SIZE = 40;
-const SWEETS_MONOS: Mono[] = [{
+export const SWEETS_MONOS: Mono[] = [{
 	id: '77f724c0-88be-4aeb-8e1a-a00ed18e3844',
 	level: 10,
 	sizeX: SWEETS_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
@@ -976,465 +950,3 @@ const SWEETS_MONOS: Mono[] = [{
 	score: 30,
 	dropCandidate: true,
 }];
-
-export class DropAndFusionGame extends EventEmitter<{
-	changeScore: (newScore: number) => void;
-	changeCombo: (newCombo: number) => void;
-	changeStock: (newStock: { id: string; mono: Mono }[]) => void;
-	changeHolding: (newHolding: { id: string; mono: Mono } | null) => void;
-	dropped: (x: number) => void;
-	fusioned: (x: number, y: number, nextMono: Mono | null, scoreDelta: number) => void;
-	collision: (energy: number, bodyA: Matter.Body, bodyB: Matter.Body) => void;
-	monoAdded: (mono: Mono) => void;
-	gameOver: () => void;
-}> {
-	private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
-	private COMBO_INTERVAL = 60; // frame
-	public readonly GAME_VERSION = 3;
-	public readonly GAME_WIDTH = 450;
-	public readonly GAME_HEIGHT = 600;
-	public readonly DROP_COOLTIME = 30; // frame
-	public readonly PLAYAREA_MARGIN = 25;
-	private STOCK_MAX = 4;
-	private TICK_DELTA = 1000 / 60; // 60fps
-
-	public frame = 0;
-	public engine: Matter.Engine;
-	private tickCallbackQueue: { frame: number; callback: () => void; }[] = [];
-	private overflowCollider: Matter.Body;
-	private isGameOver = false;
-	private gameMode: 'normal' | 'yen' | 'square' | 'sweets' | 'space';
-	private rng: () => number;
-	private logs: Log[] = [];
-
-	/**
-	 * フィールドに出ていて、かつ合体の対象となるアイテム
-	 */
-	private fusionReadyBodyIds: Matter.Body['id'][] = [];
-
-	private gameOverReadyBodyIds: Matter.Body['id'][] = [];
-
-	/**
-	 * fusion予約アイテムのペア
-	 * TODO: これらのモノは光らせるなどの演出をすると視覚的に楽しそう
-	 */
-	private fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
-
-	private latestDroppedAt = 0; // frame
-	private latestFusionedAt = 0; // frame
-	private stock: { id: string; mono: Mono }[] = [];
-	private holding: { id: string; mono: Mono } | null = null;
-
-	public get monoDefinitions() {
-		switch (this.gameMode) {
-			case 'normal': return NORAML_MONOS;
-			case 'yen': return YEN_MONOS;
-			case 'square': return SQUARE_MONOS;
-			case 'sweets': return SWEETS_MONOS;
-			case 'space': return NORAML_MONOS;
-		}
-	}
-
-	private _combo = 0;
-	private get combo() {
-		return this._combo;
-	}
-	private set combo(value: number) {
-		this._combo = value;
-		this.emit('changeCombo', value);
-	}
-
-	private _score = 0;
-	private get score() {
-		return this._score;
-	}
-	private set score(value: number) {
-		this._score = value;
-		this.emit('changeScore', value);
-	}
-
-	private getMonoRenderOptions: null | ((mono: Mono) => Partial<Matter.IBodyRenderOptions>) = null;
-
-	public replayPlaybackRate = 1;
-
-	constructor(env: {
-		seed: string;
-		gameMode: DropAndFusionGame['gameMode'];
-		getMonoRenderOptions?: (mono: Mono) => Partial<Matter.IBodyRenderOptions>;
-	}) {
-		super();
-
-		//#region BIND
-		this.tick = this.tick.bind(this);
-		//#endregion
-
-		this.gameMode = env.gameMode;
-		this.getMonoRenderOptions = env.getMonoRenderOptions ?? null;
-		this.rng = seedrandom(env.seed);
-
-		// sweetsモードは重いため
-		const physicsQualityFactor = this.gameMode === 'sweets' ? 4 : this.PHYSICS_QUALITY_FACTOR;
-		this.engine = Matter.Engine.create({
-			constraintIterations: 2 * physicsQualityFactor,
-			positionIterations: 6 * physicsQualityFactor,
-			velocityIterations: 4 * physicsQualityFactor,
-			gravity: {
-				x: 0,
-				y: this.gameMode === 'space' ? 0.0125 : 1,
-			},
-			timing: {
-				timeScale: 2,
-			},
-			enableSleeping: false,
-		});
-
-		this.engine.world.bodies = [];
-
-		//#region walls
-		const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
-			label: '_wall_',
-			isStatic: true,
-			friction: 0.7,
-			slop: this.gameMode === 'space' ? 0.01 : 0.7,
-			render: {
-				strokeStyle: 'transparent',
-				fillStyle: 'transparent',
-			},
-		};
-
-		const thickness = 100;
-		Matter.Composite.add(this.engine.world, [
-			Matter.Bodies.rectangle(this.GAME_WIDTH / 2, this.GAME_HEIGHT + (thickness / 2) - this.PLAYAREA_MARGIN, this.GAME_WIDTH, thickness, WALL_OPTIONS),
-			Matter.Bodies.rectangle(this.GAME_WIDTH + (thickness / 2) - this.PLAYAREA_MARGIN, this.GAME_HEIGHT / 2, thickness, this.GAME_HEIGHT, WALL_OPTIONS),
-			Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), this.GAME_HEIGHT / 2, thickness, this.GAME_HEIGHT, WALL_OPTIONS),
-		]);
-		//#endregion
-
-		this.overflowCollider = Matter.Bodies.rectangle(this.GAME_WIDTH / 2, 0, this.GAME_WIDTH, 200, {
-			label: '_overflow_',
-			isStatic: true,
-			isSensor: true,
-			render: {
-				strokeStyle: 'transparent',
-				fillStyle: 'transparent',
-			},
-		});
-		Matter.Composite.add(this.engine.world, this.overflowCollider);
-	}
-
-	public msToFrame(ms: number) {
-		return Math.round(ms / this.TICK_DELTA);
-	}
-
-	public frameToMs(frame: number) {
-		return frame * this.TICK_DELTA;
-	}
-
-	private createBody(mono: Mono, x: number, y: number) {
-		const options: Matter.IBodyDefinition = {
-			label: mono.id,
-			density: this.gameMode === 'space' ? 0.01 : ((mono.sizeX * mono.sizeY) / 10000),
-			restitution: this.gameMode === 'space' ? 0.5 : 0.2,
-			frictionAir: this.gameMode === 'space' ? 0 : 0.01,
-			friction: this.gameMode === 'space' ? 0.5 : 0.7,
-			frictionStatic: this.gameMode === 'space' ? 0 : 5,
-			slop: this.gameMode === 'space' ? 0.01 : 0.7,
-			//mass: 0,
-			render: this.getMonoRenderOptions ? this.getMonoRenderOptions(mono) : undefined,
-		};
-		if (mono.shape === 'circle') {
-			return Matter.Bodies.circle(x, y, mono.sizeX / 2, options);
-		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-		} else if (mono.shape === 'rectangle') {
-			return Matter.Bodies.rectangle(x, y, mono.sizeX, mono.sizeY, options);
-		} else if (mono.shape === 'custom') {
-			return Matter.Bodies.fromVertices(x, y, mono.vertices!.map(i => i.map(j => ({
-				x: (j.x / mono.verticesSize!) * mono.sizeX,
-				y: (j.y / mono.verticesSize!) * mono.sizeY,
-			}))), options);
-		} else {
-			throw new Error('unrecognized shape');
-		}
-	}
-
-	private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
-		if (this.latestFusionedAt > this.frame - this.COMBO_INTERVAL) {
-			this.combo++;
-		} else {
-			this.combo = 1;
-		}
-		this.latestFusionedAt = this.frame;
-
-		const newX = (bodyA.position.x + bodyB.position.x) / 2;
-		const newY = (bodyA.position.y + bodyB.position.y) / 2;
-
-		this.fusionReadyBodyIds = this.fusionReadyBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
-		this.gameOverReadyBodyIds = this.gameOverReadyBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
-		Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
-
-		const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
-		const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1) ?? null;
-
-		if (nextMono) {
-			const body = this.createBody(nextMono, newX, newY);
-			Matter.Composite.add(this.engine.world, body);
-
-			// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
-			this.tickCallbackQueue.push({
-				frame: this.frame + this.msToFrame(100),
-				callback: () => {
-					this.fusionReadyBodyIds.push(body.id);
-				},
-			});
-
-			this.emit('monoAdded', nextMono);
-		}
-
-		const hasComboBonus = this.gameMode !== 'yen' && this.gameMode !== 'sweets';
-		const comboBonus = hasComboBonus ? 1 + ((this.combo - 1) / 5) : 1;
-		const additionalScore = Math.round(currentMono.score * comboBonus);
-		this.score += additionalScore;
-
-		this.emit('fusioned', newX, newY, nextMono, additionalScore);
-	}
-
-	private onCollision(event: Matter.IEventCollision<Matter.Engine>) {
-		for (const pairs of event.pairs) {
-			const { bodyA, bodyB } = pairs;
-
-			const shouldFusion = (bodyA.label === bodyB.label) &&
-				!this.fusionReservedPairs.some(x =>
-					x.bodyA.id === bodyA.id ||
-					x.bodyA.id === bodyB.id ||
-					x.bodyB.id === bodyA.id ||
-					x.bodyB.id === bodyB.id);
-
-			if (shouldFusion) {
-				if (this.fusionReadyBodyIds.includes(bodyA.id) && this.fusionReadyBodyIds.includes(bodyB.id)) {
-					this.fusion(bodyA, bodyB);
-				} else {
-					this.fusionReservedPairs.push({ bodyA, bodyB });
-					this.tickCallbackQueue.push({
-						frame: this.frame + this.msToFrame(100),
-						callback: () => {
-							this.fusionReservedPairs = this.fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
-							this.fusion(bodyA, bodyB);
-						},
-					});
-				}
-			} else {
-				const energy = pairs.collision.depth;
-
-				if (bodyA.label === '_overflow_' || bodyB.label === '_overflow_') continue;
-
-				if (bodyA.label !== '_wall_' && bodyB.label !== '_wall_') {
-					if (!this.gameOverReadyBodyIds.includes(bodyA.id)) this.gameOverReadyBodyIds.push(bodyA.id);
-					if (!this.gameOverReadyBodyIds.includes(bodyB.id)) this.gameOverReadyBodyIds.push(bodyB.id);
-				}
-
-				this.emit('collision', energy, bodyA, bodyB);
-			}
-		}
-	}
-
-	private onCollisionActive(event: Matter.IEventCollision<Matter.Engine>) {
-		for (const pairs of event.pairs) {
-			const { bodyA, bodyB } = pairs;
-
-			// ハコからあふれたかどうかの判定
-			if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) {
-				if (this.gameOverReadyBodyIds.includes(bodyA.id) || this.gameOverReadyBodyIds.includes(bodyB.id)) {
-					this.gameOver();
-					break;
-				}
-				continue;
-			}
-		}
-	}
-
-	public surrender() {
-		this.logs.push({
-			frame: this.frame,
-			operation: 'surrender',
-		});
-
-		this.gameOver();
-	}
-
-	private gameOver() {
-		this.isGameOver = true;
-		this.emit('gameOver');
-	}
-
-	public start() {
-		for (let i = 0; i < this.STOCK_MAX; i++) {
-			this.stock.push({
-				id: this.rng().toString(),
-				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
-			});
-		}
-		this.emit('changeStock', this.stock);
-
-		Matter.Events.on(this.engine, 'collisionStart', this.onCollision.bind(this));
-		Matter.Events.on(this.engine, 'collisionActive', this.onCollisionActive.bind(this));
-	}
-
-	public getLogs() {
-		return this.logs;
-	}
-
-	public tick() {
-		this.frame++;
-
-		if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
-			this.combo = 0;
-		}
-
-		this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
-			if (x.frame === this.frame) {
-				x.callback();
-				return false;
-			} else {
-				return true;
-			}
-		});
-
-		Matter.Engine.update(this.engine, this.TICK_DELTA);
-
-		const hasNextTick = !this.isGameOver;
-
-		return hasNextTick;
-	}
-
-	public getActiveMonos() {
-		return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined);
-	}
-
-	public drop(_x: number) {
-		if (this.isGameOver) return;
-		if (this.frame - this.latestDroppedAt < this.DROP_COOLTIME) return;
-
-		const head = this.stock.shift()!;
-		this.stock.push({
-			id: this.rng().toString(),
-			mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
-		});
-		this.emit('changeStock', this.stock);
-
-		const inputX = Math.round(_x);
-		const x = Math.min(this.GAME_WIDTH - this.PLAYAREA_MARGIN - (head.mono.sizeX / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.sizeX / 2), inputX));
-		const body = this.createBody(head.mono, x, 50 + head.mono.sizeY / 2);
-		this.logs.push({
-			frame: this.frame,
-			operation: 'drop',
-			x: inputX,
-		});
-
-		// add force
-		if (this.gameMode === 'space') {
-			Matter.Body.applyForce(body, body.position, {
-				x: 0,
-				y: (Math.PI * head.mono.sizeX * head.mono.sizeY) / 65536,
-			});
-		}
-
-		Matter.Composite.add(this.engine.world, body);
-
-		this.fusionReadyBodyIds.push(body.id);
-		this.latestDroppedAt = this.frame;
-
-		this.emit('dropped', x);
-		this.emit('monoAdded', head.mono);
-	}
-
-	public hold() {
-		if (this.isGameOver) return;
-
-		this.logs.push({
-			frame: this.frame,
-			operation: 'hold',
-		});
-
-		if (this.holding) {
-			const head = this.stock.shift()!;
-			this.stock.unshift(this.holding);
-			this.holding = head;
-			this.emit('changeHolding', this.holding);
-			this.emit('changeStock', this.stock);
-		} else {
-			const head = this.stock.shift()!;
-			this.holding = head;
-			this.stock.push({
-				id: this.rng().toString(),
-				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
-			});
-			this.emit('changeHolding', this.holding);
-			this.emit('changeStock', this.stock);
-		}
-	}
-
-	public static serializeLogs(logs: Log[]) {
-		const _logs: number[][] = [];
-
-		for (let i = 0; i < logs.length; i++) {
-			const log = logs[i];
-			const frameDelta = i === 0 ? log.frame : log.frame - logs[i - 1].frame;
-
-			switch (log.operation) {
-				case 'drop':
-					_logs.push([frameDelta, 0, log.x]);
-					break;
-				case 'hold':
-					_logs.push([frameDelta, 1]);
-					break;
-				case 'surrender':
-					_logs.push([frameDelta, 2]);
-					break;
-			}
-		}
-
-		return _logs;
-	}
-
-	public static deserializeLogs(logs: number[][]) {
-		const _logs: Log[] = [];
-
-		let frame = 0;
-
-		for (const log of logs) {
-			const frameDelta = log[0];
-			frame += frameDelta;
-
-			const operation = log[1];
-
-			switch (operation) {
-				case 0:
-					_logs.push({
-						frame,
-						operation: 'drop',
-						x: log[2],
-					});
-					break;
-				case 1:
-					_logs.push({
-						frame,
-						operation: 'hold',
-					});
-					break;
-				case 2:
-					_logs.push({
-						frame,
-						operation: 'surrender',
-					});
-					break;
-			}
-		}
-
-		return _logs;
-	}
-
-	public dispose() {
-		Matter.World.clear(this.engine.world, false);
-		Matter.Engine.clear(this.engine);
-	}
-}
diff --git a/packages/misskey-bubble-game/tsconfig.json b/packages/misskey-bubble-game/tsconfig.json
new file mode 100644
index 0000000000..f56b65e868
--- /dev/null
+++ b/packages/misskey-bubble-game/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/packages/misskey-reversi/.eslintignore b/packages/misskey-reversi/.eslintignore
new file mode 100644
index 0000000000..f22128f047
--- /dev/null
+++ b/packages/misskey-reversi/.eslintignore
@@ -0,0 +1,7 @@
+node_modules
+/built
+/coverage
+/.eslintrc.js
+/jest.config.ts
+/test
+/test-d
diff --git a/packages/misskey-reversi/.eslintrc.cjs b/packages/misskey-reversi/.eslintrc.cjs
new file mode 100644
index 0000000000..e2e31e9e33
--- /dev/null
+++ b/packages/misskey-reversi/.eslintrc.cjs
@@ -0,0 +1,9 @@
+module.exports = {
+	parserOptions: {
+		tsconfigRootDir: __dirname,
+		project: ['./tsconfig.json'],
+	},
+	extends: [
+		'../shared/.eslintrc.js',
+	],
+};
diff --git a/packages/misskey-reversi/src/index.ts b/packages/misskey-reversi/src/index.ts
index 20ed36f208..28964413b7 100644
--- a/packages/misskey-reversi/src/index.ts
+++ b/packages/misskey-reversi/src/index.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
 import { Game } from './game.js';
 
 export {
diff --git a/packages/misskey-reversi/src/maps.ts b/packages/misskey-reversi/src/maps.ts
index 85cf1a0485..b47a996c7c 100644
--- a/packages/misskey-reversi/src/maps.ts
+++ b/packages/misskey-reversi/src/maps.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
 /**
  * 組み込みマップ定義
  *
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 31394eb081..0a80403725 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -778,6 +778,9 @@ importers:
       mfm-js:
         specifier: 0.24.0
         version: 0.24.0
+      misskey-bubble-game:
+        specifier: workspace:*
+        version: link:../misskey-bubble-game
       misskey-js:
         specifier: workspace:*
         version: link:../misskey-js
@@ -799,9 +802,6 @@ importers:
       sass:
         specifier: 1.69.5
         version: 1.69.5
-      seedrandom:
-        specifier: ^3.0.5
-        version: 3.0.5
       shiki:
         specifier: 0.14.7
         version: 0.14.7
@@ -1026,6 +1026,43 @@ importers:
         specifier: 1.8.27
         version: 1.8.27(typescript@5.3.3)
 
+  packages/misskey-bubble-game:
+    dependencies:
+      eventemitter3:
+        specifier: 5.0.1
+        version: 5.0.1
+      matter-js:
+        specifier: 0.19.0
+        version: 0.19.0
+      seedrandom:
+        specifier: 3.0.5
+        version: 3.0.5
+    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/matter-js':
+        specifier: 0.19.6
+        version: 0.19.6
+      '@types/node':
+        specifier: 20.11.5
+        version: 20.11.5
+      '@types/seedrandom':
+        specifier: 3.0.8
+        version: 3.0.8
+      '@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/misskey-js:
     dependencies:
       '@swc/cli':
@@ -1845,7 +1882,7 @@ packages:
       '@babel/traverse': 7.22.11
       '@babel/types': 7.22.17
       convert-source-map: 1.9.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -1868,7 +1905,7 @@ packages:
       '@babel/traverse': 7.23.5
       '@babel/types': 7.23.5
       convert-source-map: 2.0.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -1970,7 +2007,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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -3369,7 +3406,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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -3387,7 +3424,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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -4266,7 +4303,7 @@ packages:
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
     dependencies:
       ajv: 6.12.6
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       espree: 9.6.1
       globals: 13.19.0
       ignore: 5.2.4
@@ -4283,7 +4320,7 @@ packages:
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
     dependencies:
       ajv: 6.12.6
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       espree: 9.6.1
       globals: 13.19.0
       ignore: 5.2.4
@@ -4548,7 +4585,7 @@ packages:
     engines: {node: '>=10.10.0'}
     dependencies:
       '@humanwhocodes/object-schema': 2.0.1
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       minimatch: 3.1.2
     transitivePeerDependencies:
       - supports-color
@@ -5150,7 +5187,7 @@ packages:
       '@open-draft/until': 1.0.3
       '@types/debug': 4.1.7
       '@xmldom/xmldom': 0.8.6
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       headers-polyfill: 3.2.5
       outvariant: 1.4.0
       strict-event-emitter: 0.2.8
@@ -8289,6 +8326,10 @@ packages:
     resolution: {integrity: sha512-pTVB5krRGb01hr8L6BJqWGoSriqUbbvJ9Fd0Qp0eAOE//w/lFvkaVHkVB8J3wXr9U3lZDzmAjJPPQn7x4wzbWg==}
     dev: true
 
+  /@types/matter-js@0.19.6:
+    resolution: {integrity: sha512-ffk6tqJM5scla+ThXmnox+mdfCo3qYk6yMjQsNcrbo6eQ5DqorVdtnaL+1agCoYzxUjmHeiNB7poBMAmhuLY7w==}
+    dev: true
+
   /@types/mdx@2.0.3:
     resolution: {integrity: sha512-IgHxcT3RC8LzFLhKwP3gbMPeaK7BM9eBH46OdapPA7yvuIUJ8H6zHZV53J8hGZcTSnt95jANt+rTBNUUc22ACQ==}
     dev: true
@@ -8479,6 +8520,10 @@ packages:
     requiresBuild: true
     dev: false
 
+  /@types/seedrandom@3.0.8:
+    resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==}
+    dev: true
+
   /@types/semver@7.5.6:
     resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==}
     dev: true
@@ -8621,7 +8666,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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.53.0
       graphemer: 1.4.0
       ignore: 5.2.4
@@ -8650,7 +8695,7 @@ 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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       graphemer: 1.4.0
       ignore: 5.2.4
@@ -8679,7 +8724,7 @@ packages:
       '@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)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       graphemer: 1.4.0
       ignore: 5.2.4
@@ -8708,7 +8753,7 @@ packages:
       '@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)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       graphemer: 1.4.0
       ignore: 5.2.4
@@ -8734,7 +8779,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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.53.0
       typescript: 5.3.3
     transitivePeerDependencies:
@@ -8755,7 +8800,7 @@ 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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       typescript: 5.3.3
     transitivePeerDependencies:
@@ -8776,7 +8821,7 @@ packages:
       '@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)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       typescript: 5.3.3
     transitivePeerDependencies:
@@ -8819,7 +8864,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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.53.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
       typescript: 5.3.3
@@ -8839,7 +8884,7 @@ 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@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
       typescript: 5.3.3
@@ -8859,7 +8904,7 @@ packages:
     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)
+      debug: 4.3.4(supports-color@8.1.1)
       eslint: 8.56.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
       typescript: 5.3.3
@@ -8893,7 +8938,7 @@ packages:
     dependencies:
       '@typescript-eslint/types': 6.11.0
       '@typescript-eslint/visitor-keys': 6.11.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       globby: 11.1.0
       is-glob: 4.0.3
       semver: 7.5.4
@@ -8914,7 +8959,7 @@ packages:
     dependencies:
       '@typescript-eslint/types': 6.14.0
       '@typescript-eslint/visitor-keys': 6.14.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       globby: 11.1.0
       is-glob: 4.0.3
       semver: 7.5.4
@@ -8935,7 +8980,7 @@ packages:
     dependencies:
       '@typescript-eslint/types': 6.19.0
       '@typescript-eslint/visitor-keys': 6.19.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       globby: 11.1.0
       is-glob: 4.0.3
       minimatch: 9.0.3
@@ -9420,7 +9465,7 @@ packages:
     engines: {node: '>= 6.0.0'}
     requiresBuild: true
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
 
@@ -9428,7 +9473,7 @@ packages:
     resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==}
     engines: {node: '>= 14'}
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -9814,7 +9859,7 @@ packages:
     resolution: {integrity: sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw==}
     dependencies:
       archy: 1.0.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       fastq: 1.15.0
     transitivePeerDependencies:
       - supports-color
@@ -11263,6 +11308,7 @@ 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==}
@@ -11275,7 +11321,6 @@ 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==}
@@ -11492,7 +11537,7 @@ packages:
     hasBin: true
     dependencies:
       address: 1.2.2
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
     dev: true
@@ -11816,7 +11861,7 @@ packages:
     peerDependencies:
       esbuild: '>=0.12 <1'
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       esbuild: 0.18.20
     transitivePeerDependencies:
       - supports-color
@@ -12218,7 +12263,7 @@ packages:
       ajv: 6.12.6
       chalk: 4.1.2
       cross-spawn: 7.0.3
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       doctrine: 3.0.0
       escape-string-regexp: 4.0.0
       eslint-scope: 7.2.2
@@ -12265,7 +12310,7 @@ packages:
       ajv: 6.12.6
       chalk: 4.1.2
       cross-spawn: 7.0.3
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       doctrine: 3.0.0
       escape-string-regexp: 4.0.0
       eslint-scope: 7.2.2
@@ -12895,7 +12940,7 @@ packages:
       debug:
         optional: true
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
 
   /for-each@0.3.3:
     resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -13451,6 +13496,7 @@ 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==}
@@ -13588,7 +13634,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -13650,7 +13696,7 @@ packages:
     engines: {node: '>= 6.0.0'}
     dependencies:
       agent-base: 5.1.1
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
     dev: true
@@ -13660,7 +13706,7 @@ packages:
     engines: {node: '>= 6'}
     dependencies:
       agent-base: 6.0.2
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
 
@@ -13669,7 +13715,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -13679,7 +13725,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -13839,7 +13885,7 @@ packages:
     dependencies:
       '@ioredis/commands': 1.2.0
       cluster-key-slot: 1.1.2
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       denque: 2.1.0
       lodash.defaults: 4.2.0
       lodash.isarguments: 3.1.0
@@ -14280,7 +14326,7 @@ packages:
     resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
     engines: {node: '>=10'}
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       istanbul-lib-coverage: 3.2.0
       source-map: 0.6.1
     transitivePeerDependencies:
@@ -14997,7 +15043,7 @@ packages:
     resolution: {integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==}
     engines: {node: '>=10'}
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       rfdc: 1.3.0
       uri-js: 4.4.1
     transitivePeerDependencies:
@@ -17608,7 +17654,7 @@ packages:
     engines: {node: '>=8.16.0'}
     dependencies:
       '@types/mime-types': 2.1.4
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       extract-zip: 1.7.0
       https-proxy-agent: 4.0.0
       mime: 2.6.0
@@ -18605,7 +18651,7 @@ packages:
     dependencies:
       '@hapi/hoek': 10.0.1
       '@hapi/wreck': 18.0.1
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       joi: 17.7.0
     transitivePeerDependencies:
       - supports-color
@@ -18805,7 +18851,7 @@ packages:
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       socks: 2.7.1
     transitivePeerDependencies:
       - supports-color
@@ -18958,7 +19004,7 @@ packages:
       arg: 5.0.2
       bluebird: 3.7.2
       check-more-types: 2.24.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       execa: 5.1.1
       lazy-ass: 1.6.0
       ps-tree: 1.2.0
@@ -19222,6 +19268,7 @@ 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==}
@@ -19844,7 +19891,7 @@ packages:
       chalk: 4.1.2
       cli-highlight: 2.1.11
       date-fns: 2.30.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       dotenv: 16.0.3
       glob: 8.1.0
       ioredis: 5.3.2
@@ -20209,7 +20256,7 @@ packages:
     hasBin: true
     dependencies:
       cac: 6.7.14
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       mlly: 1.4.0
       pathe: 1.1.1
       picocolors: 1.0.0
@@ -20321,7 +20368,7 @@ packages:
       acorn-walk: 8.2.0
       cac: 6.7.14
       chai: 4.3.10
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       happy-dom: 10.0.3
       local-pkg: 0.4.3
       magic-string: 0.30.3
@@ -20403,7 +20450,7 @@ packages:
     peerDependencies:
       eslint: '>=6.0.0'
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.4(supports-color@8.1.1)
       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 3a03a58253..193669e7a4 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,3 +5,4 @@ packages:
  - 'packages/misskey-js'
  - 'packages/misskey-js/generator'
  - 'packages/misskey-reversi'
+ - 'packages/misskey-bubble-game'
-- 
GitLab