diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index d041a675f896dea98e1fcc2c7cad7f2d229de574..f585519459e8a8f353abdeda697372ad392aaa4c 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -103,7 +103,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div v-if="replaying" style="display: flex;">
 				<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
 					<div :class="$style.frameInner">
-						<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END REPLAY</MkButton>
+						<div class="_buttonsCenter">
+							<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END REPLAY</MkButton>
+							<MkButton :primary="replayPlaybackRate === 2" @click="replayPlaybackRate = replayPlaybackRate === 2 ? 1 : 2"><i class="ti ti-player-track-next"></i> x2</MkButton>
+							<MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton>
+						</div>
 					</div>
 				</div>
 			</div>
@@ -437,10 +441,15 @@ const gameStarted = ref(false);
 const highScore = ref<number | null>(null);
 const showConfig = ref(false);
 const replaying = ref(false);
+const replayPlaybackRate = ref(1);
 const mute = ref(false);
 const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
 const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
 
+watch(replayPlaybackRate, (newValue) => {
+	game.replayPlaybackRate = newValue;
+});
+
 function onClick(ev: MouseEvent) {
 	if (!containerElRect) return;
 	if (replaying.value) return;
@@ -493,6 +502,7 @@ function end() {
 	game.dispose();
 	isGameOver.value = false;
 	replaying.value = false;
+	replayPlaybackRate.value = 1;
 	currentPick.value = null;
 	dropReady.value = true;
 	stock.value = [];
diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
index 16fe87d97adeb4c8eb8d0e9adde458eb2174533f..a59eb271ec76ea2bcafe6f457cc0db2a5f7545bd 100644
--- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts
+++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
@@ -44,7 +44,7 @@ export class DropAndFusionGame extends EventEmitter<{
 	gameOver: () => void;
 }> {
 	private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
-	private COMBO_INTERVAL = 1000;
+	private COMBO_INTERVAL = 60; // frame
 	public readonly DROP_INTERVAL = 500;
 	public readonly PLAYAREA_MARGIN = 25;
 	private STOCK_MAX = 4;
@@ -76,7 +76,7 @@ export class DropAndFusionGame extends EventEmitter<{
 	private latestDroppedBodyId: Matter.Body['id'] | null = null;
 
 	private latestDroppedAt = 0;
-	private latestFusionedAt = 0;
+	private latestFusionedAt = 0; // frame
 	private stock: { id: string; mono: Mono }[] = [];
 	private holding: { id: string; mono: Mono } | null = null;
 
@@ -100,6 +100,8 @@ export class DropAndFusionGame extends EventEmitter<{
 
 	private comboIntervalId: number | null = null;
 
+	public replayPlaybackRate = 1;
+
 	constructor(opts: {
 		canvas: HTMLCanvasElement;
 		width: number;
@@ -219,13 +221,12 @@ export class DropAndFusionGame extends EventEmitter<{
 	}
 
 	private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
-		const now = Date.now();
-		if (this.latestFusionedAt > now - this.COMBO_INTERVAL) {
+		if (this.latestFusionedAt > this.frame - this.COMBO_INTERVAL) {
 			this.combo++;
 		} else {
 			this.combo = 1;
 		}
-		this.latestFusionedAt = now;
+		this.latestFusionedAt = this.frame;
 
 		// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する?
 		const newX = (bodyA.position.x + bodyB.position.x) / 2;
@@ -390,44 +391,43 @@ export class DropAndFusionGame extends EventEmitter<{
 			}
 		});
 
-		this.comboIntervalId = window.setInterval(() => {
-			if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
-				this.combo = 0;
-			}
-		}, 500);
-
 		if (logs) {
 			const playTick = () => {
-				this.frame++;
-				const log = logs.find(x => x.frame === this.frame - 1);
-				if (log) {
-					switch (log.operation) {
-						case 'drop': {
-							this.drop(log.x);
-							break;
-						}
-						case 'hold': {
-							this.hold();
-							break;
-						}
-						case 'surrender': {
-							this.surrender();
-							break;
-						}
-						default:
-							break;
+				for (let i = 0; i < this.replayPlaybackRate; i++) {
+					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;
+					const log = logs.find(x => x.frame === this.frame - 1);
+					if (log) {
+						switch (log.operation) {
+							case 'drop': {
+								this.drop(log.x);
+								break;
+							}
+							case 'hold': {
+								this.hold();
+								break;
+							}
+							case 'surrender': {
+								this.surrender();
+								break;
+							}
+							default:
+								break;
+						}
 					}
-				});
+					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);
+					Matter.Engine.update(this.engine, this.TICK_DELTA);
+				}
 
 				if (!this.isGameOver) {
 					this.tickRaf = window.requestAnimationFrame(playTick);
@@ -446,6 +446,9 @@ export class DropAndFusionGame extends EventEmitter<{
 
 	private 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();