diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 0fd9bf6bb0..a93dd5e274 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -215,11 +215,13 @@ export interface MahjongRoomEventTypes {
 	dahai: {
 		house: Mahjong.Common.House;
 		tile: Mahjong.Common.Tile;
+		riichi: boolean;
 	};
 	dahaiAndTsumo: {
 		dahaiHouse: Mahjong.Common.House;
 		dahaiTile: Mahjong.Common.Tile;
 		tsumoTile: Mahjong.Common.Tile;
+		riichi: boolean;
 	};
 	ponned: {
 		caller: Mahjong.Common.House;
diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts
index 0120b36e8a..5903284b6e 100644
--- a/packages/backend/src/core/MahjongService.ts
+++ b/packages/backend/src/core/MahjongService.ts
@@ -26,7 +26,7 @@ import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js
 import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
 
 const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
-const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 5; // 5sec
+const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 7; // 7sec
 const TURN_TIMEOUT_MS = 1000 * 30; // 30sec
 const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec
 
@@ -318,7 +318,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
 		switch (res.type) {
 			case 'tsumo':
 				this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile });
-				this.next(room, engine);
+				this.waitForTurn(room, res.turn, engine);
 				break;
 			case 'ponned':
 				this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { caller: res.caller, callee: res.callee, tile: res.tile });
@@ -349,23 +349,6 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
 		}
 	}
 
-	@bindThis
-	private async next(room: Room, engine: Mahjong.MasterGameEngine) {
-		const turn = engine.state.turn;
-		if (turn == null) throw new Error('turn is null');
-
-		const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => engine.getHouse(id));
-
-		if (aiHouses.includes(turn)) {
-			// TODO: ちゃんと思考するようにする
-			setTimeout(() => {
-				this.dahai(room, engine, turn, engine.state.handTiles[turn].at(-1));
-			}, 500);
-		} else {
-			this.waitForTurn(room, turn, engine);
-		}
-	}
-
 	@bindThis
 	private async endKyoku(room: Room, engine: Mahjong.MasterGameEngine) {
 		const confirmation: NextKyokuConfirmation = {
@@ -469,11 +452,11 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
 				}
 			}, 1000);
 
-			this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: house, tile });
+			this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: house, tile, riichi });
 		} else {
-			this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { dahaiHouse: house, dahaiTile: tile, tsumoTile: res.tsumoTile });
+			this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { dahaiHouse: house, dahaiTile: tile, tsumoTile: res.tsumoTile, riichi });
 
-			this.next(room, engine);
+			this.waitForTurn(room, res.next, engine);
 		}
 	}
 
@@ -556,7 +539,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
 
 		const res = engine.commit_tsumoHora(myHouse);
 
-		this.globalEventService.publishMahjongRoomStream(room.id, 'tsumoHora', { });
+		this.globalEventService.publishMahjongRoomStream(room.id, 'tsumoHora', { house: myHouse, handTiles: res.handTiles, tsumoTile: res.tsumoTile });
 	}
 
 	@bindThis
@@ -624,6 +607,8 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
 	 */
 	@bindThis
 	private async waitForTurn(room: Room, house: Mahjong.Common.House, engine: Mahjong.MasterGameEngine) {
+		const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => engine.getHouse(id));
+
 		if (engine.state.riichis[house]) {
 			// リーチ時はアガリ牌でない限りツモ切り
 			const handTiles = engine.state.handTiles[house];
@@ -636,6 +621,13 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
 			}
 		}
 
+		if (aiHouses.includes(house)) {
+			setTimeout(() => {
+				this.dahai(room, engine, house, engine.state.handTiles[house].at(-1));
+			}, 500);
+			return;
+		}
+
 		const id = Math.random().toString(36).slice(2);
 		console.log('waitForTurn', house, id);
 		this.redisClient.sadd(`mahjong:gameTurnWaiting:${room.id}`, id);
diff --git a/packages/frontend/assets/mahjong/riichi.png b/packages/frontend/assets/mahjong/riichi.png
new file mode 100644
index 0000000000..2860f5dc65
Binary files /dev/null and b/packages/frontend/assets/mahjong/riichi.png differ
diff --git a/packages/frontend/assets/mahjong/ron.png b/packages/frontend/assets/mahjong/ron.png
index 6e1f889865..c508f81adb 100644
Binary files a/packages/frontend/assets/mahjong/ron.png and b/packages/frontend/assets/mahjong/ron.png differ
diff --git a/packages/frontend/assets/mahjong/tsumo.png b/packages/frontend/assets/mahjong/tsumo.png
index 14577818ab..547d01cbb7 100644
Binary files a/packages/frontend/assets/mahjong/tsumo.png and b/packages/frontend/assets/mahjong/tsumo.png differ
diff --git a/packages/frontend/src/pages/mahjong/huro.vue b/packages/frontend/src/pages/mahjong/huro.vue
new file mode 100644
index 0000000000..1eaa5971f1
--- /dev/null
+++ b/packages/frontend/src/pages/mahjong/huro.vue
@@ -0,0 +1,46 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div v-if="huro.type === 'ankan'" :class="[$style.root]">
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+</div>
+<div v-else-if="huro.type === 'minkan'" :class="[$style.root]">
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+</div>
+<div v-else-if="huro.type === 'cii'" :class="[$style.root]">
+	<XTile :tile="huro.tiles[0]" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tiles[1]" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tiles[2]" :variation="variation" :doras="doras"/>
+</div>
+<div v-else :class="[$style.root]">
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+	<XTile :tile="huro.tile" :variation="variation" :doras="doras"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Mahjong from 'misskey-mahjong';
+import XTile from './tile.vue';
+
+const props = defineProps<{
+	huro: Mahjong.Common.Huro;
+	variation: string;
+	doras: Mahjong.Common.Tile[];
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+}
+</style>
diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue
index ec9bc2a298..c44b708685 100644
--- a/packages/frontend/src/pages/mahjong/room.game.vue
+++ b/packages/frontend/src/pages/mahjong/room.game.vue
@@ -56,6 +56,22 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</div>
 
+		<div :class="$style.huroTilesOfToimen">
+			<XHuro v-for="huro in engine.state.huros[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :huro="huro" :variation="1" :doras="engine.doras"/>
+		</div>
+
+		<div :class="$style.huroTilesOfKamitya">
+			<XHuro v-for="huro in engine.state.huros[Mahjong.Common.prevHouse(engine.myHouse)]" :huro="huro" :variation="1" :doras="engine.doras"/>
+		</div>
+
+		<div :class="$style.huroTilesOfSimotya">
+			<XHuro v-for="huro in engine.state.huros[Mahjong.Common.nextHouse(engine.myHouse)]" :huro="huro" :variation="1" :doras="engine.doras"/>
+		</div>
+
+		<div :class="$style.huroTilesOfMe">
+			<XHuro v-for="huro in engine.state.huros[engine.myHouse]" :huro="huro" :variation="1" :doras="engine.doras"/>
+		</div>
+
 		<div :class="$style.hoTilesContainer">
 			<div :class="$style.hoTilesContainerOfToimen">
 				<div :class="$style.hoTilesOfToimen">
@@ -107,44 +123,66 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</div>
 
-		<div :class="$style.huroTilesOfMe">
-			<div v-for="huro in engine.state.huros[engine.myHouse]" style="display: inline-block;">
-				<div v-if="huro.type === 'pon'">
-					<XTile :tile="huro.tile" variation="1" :doras="engine.doras"/>
-					<XTile :tile="huro.tile" variation="1" :doras="engine.doras"/>
-					<XTile :tile="huro.tile" variation="1" :doras="engine.doras"/>
-				</div>
-			</div>
-		</div>
-
 		<div :class="$style.serifContainer">
 			<div :class="$style.serifContainerOfToimen">
-				<img v-if="ronSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ciiSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ponSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="kanSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="tsumoSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+				<Transition
+					:enterActiveClass="$style.transition_serif_enterActive"
+					:leaveActiveClass="$style.transition_serif_leaveActive"
+					:enterFromClass="$style.transition_serif_enterFrom"
+					:leaveToClass="$style.transition_serif_leaveTo"
+				>
+					<img v-if="ronSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ciiSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ponSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="kanSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="tsumoSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="riichiSerifHouses[Mahjong.Common.prevHouse(Mahjong.Common.prevHouse(engine.myHouse))]" :src="`/client-assets/mahjong/riichi.png`" style="display: block; width: 100%;"/>
+				</Transition>
 			</div>
 			<div :class="$style.serifContainerOfKamitya">
-				<img v-if="ronSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ciiSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ponSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="kanSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="tsumoSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+				<Transition
+					:enterActiveClass="$style.transition_serif_enterActive"
+					:leaveActiveClass="$style.transition_serif_leaveActive"
+					:enterFromClass="$style.transition_serif_enterFrom"
+					:leaveToClass="$style.transition_serif_leaveTo"
+				>
+					<img v-if="ronSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ciiSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ponSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="kanSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="tsumoSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="riichiSerifHouses[Mahjong.Common.prevHouse(engine.myHouse)]" :src="`/client-assets/mahjong/riichi.png`" style="display: block; width: 100%;"/>
+				</Transition>
 			</div>
 			<div :class="$style.serifContainerOfSimotya">
-				<img v-if="ronSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ciiSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ponSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="kanSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="tsumoSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+				<Transition
+					:enterActiveClass="$style.transition_serif_enterActive"
+					:leaveActiveClass="$style.transition_serif_leaveActive"
+					:enterFromClass="$style.transition_serif_enterFrom"
+					:leaveToClass="$style.transition_serif_leaveTo"
+				>
+					<img v-if="ronSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ciiSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ponSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="kanSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="tsumoSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="riichiSerifHouses[Mahjong.Common.nextHouse(engine.myHouse)]" :src="`/client-assets/mahjong/riichi.png`" style="display: block; width: 100%;"/>
+				</Transition>
 			</div>
 			<div :class="$style.serifContainerOfMe">
-				<img v-if="ronSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ciiSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="ponSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="kanSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
-				<img v-else-if="tsumoSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+				<Transition
+					:enterActiveClass="$style.transition_serif_enterActive"
+					:leaveActiveClass="$style.transition_serif_leaveActive"
+					:enterFromClass="$style.transition_serif_enterFrom"
+					:leaveToClass="$style.transition_serif_leaveTo"
+				>
+					<img v-if="ronSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/ron.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ciiSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/cii.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="ponSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/pon.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="kanSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/kan.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="tsumoSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/tsumo.png`" style="display: block; width: 100%;"/>
+					<img v-else-if="riichiSerifHouses[engine.myHouse]" :src="`/client-assets/mahjong/riichi.png`" style="display: block; width: 100%;"/>
+				</Transition>
 			</div>
 		</div>
 
@@ -156,6 +194,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkButton v-if="isMyTurn && engine.canRiichi()" primary @click="riichi">Riichi</MkButton>
 		</div>
 	</div>
+	<div v-if="kyokuEnded" :class="$style.kyokuResult">
+		<div v-for="(res, house) in kyokuResults" :key="house">
+			<div v-if="res != null">
+				<div>{{ house === 'e' ? i18n.ts._mahjong.east : house === 's' ? i18n.ts._mahjong.south : house === 'w' ? i18n.ts._mahjong.west : i18n.ts._mahjong.north }}</div>
+				<div v-for="yaku in res.yakus">
+					<div>{{ yaku.name }} ({{ yaku.fan }})</div>
+				</div>
+				<div>{{ res.doraCount }} dora</div>
+				<div>{{ res.pointDeltas.e }} / {{ res.pointDeltas.s }} / {{ res.pointDeltas.w }} / {{ res.pointDeltas.n }}</div>
+			</div>
+		</div>
+	</div>
 </div>
 </template>
 
@@ -164,6 +214,7 @@ import { computed, onActivated, onDeactivated, onMounted, onUnmounted, reactive,
 import * as Misskey from 'misskey-js';
 import * as Mahjong from 'misskey-mahjong';
 import XTile from './tile.vue';
+import XHuro from './huro.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
@@ -202,6 +253,7 @@ const ciiSerifHouses = reactive<Record<Mahjong.Common.House, boolean>>({ e: fals
 const ponSerifHouses = reactive<Record<Mahjong.Common.House, boolean>>({ e: false, s: false, w: false, n: false });
 const kanSerifHouses = reactive<Record<Mahjong.Common.House, boolean>>({ e: false, s: false, w: false, n: false });
 const tsumoSerifHouses = reactive<Record<Mahjong.Common.House, boolean>>({ e: false, s: false, w: false, n: false });
+const riichiSerifHouses = reactive<Record<Mahjong.Common.House, boolean>>({ e: false, s: false, w: false, n: false });
 
 /*
 console.log(Mahjong.Common.getTilesForRiichi([
@@ -338,6 +390,19 @@ function skip() {
 
 const iTsumoed = ref(false);
 const kyokuEnded = ref(false);
+const kyokuResults = ref<Record<Mahjong.Common.House, {
+	yakus: {
+		name: string;
+		fan: number;
+	}[];
+	doraCount: number;
+	pointDeltas: Record<Mahjong.Common.House, number>;
+} | null>>({
+	e: null,
+	s: null,
+	w: null,
+	n: null,
+});
 
 function kyokuEnd() {
 	kyokuEnded.value = true;
@@ -359,9 +424,14 @@ function onStreamDahai(log) {
 	//	return;
 	//}
 
-	engine.value.commit_dahai(log.house, log.tile);
+	engine.value.commit_dahai(log.house, log.tile, log.riichi);
 	triggerRef(engine);
 
+	riichiSerifHouses[log.house] = log.riichi;
+	window.setTimeout(() => {
+		riichiSerifHouses[log.house] = false;
+	}, 2000);
+
 	myTurnTimerRmain.value = room.value.timeLimitForEachTurn;
 }
 
@@ -402,9 +472,14 @@ function onStreamDahaiAndTsumo(log) {
 		playbackRate: 1,
 	});
 
-	engine.value.commit_dahai(log.dahaiHouse, log.dahaiTile);
+	engine.value.commit_dahai(log.dahaiHouse, log.dahaiTile, log.riichi);
 	triggerRef(engine);
 
+	riichiSerifHouses[log.dahaiHouse] = log.riichi;
+	window.setTimeout(() => {
+		riichiSerifHouses[log.dahaiHouse] = false;
+	}, 2000);
+
 	window.setTimeout(() => {
 		engine.value.commit_tsumo(Mahjong.Common.nextHouse(log.dahaiHouse), log.tsumoTile);
 		triggerRef(engine);
@@ -431,9 +506,9 @@ function onStreamPonned(log) {
 	engine.value.commit_pon(log.caller, log.callee);
 	triggerRef(engine);
 
-	ponSerifHouses[log.house] = true;
+	ponSerifHouses[log.caller] = true;
 	window.setTimeout(() => {
-		ponSerifHouses[log.house] = false;
+		ponSerifHouses[log.caller] = false;
 	}, 2000);
 
 	myTurnTimerRmain.value = room.value.timeLimitForEachTurn;
@@ -442,21 +517,31 @@ function onStreamPonned(log) {
 function onStreamRonned(log) {
 	console.log('onStreamRonned', log);
 
-	engine.value.commit_ronHora(log.callers, log.callee, log.handTiles);
+	const res = engine.value.commit_ronHora(log.callers, log.callee, log.handTiles);
 	triggerRef(engine);
 
+	kyokuResults.value = res;
+	kyokuEnded.value = true;
+
 	for (const caller of log.callers) {
 		ronSerifHouses[caller] = true;
 	}
+
+	console.log('ronned', res);
 }
 
 function onStreamTsumoHora(log) {
 	console.log('onStreamTsumoHora', log);
 
+	const res = engine.value.commit_tsumoHora(log.house, log.handTiles, log.tsumoTile);
+	triggerRef(engine);
+
+	kyokuResults.value[log.house] = res;
+	kyokuEnded.value = true;
+
 	tsumoSerifHouses[log.house] = true;
 
-	engine.value.commit_tsumoHora();
-	triggerRef(engine);
+	console.log('tsumohora', res);
 }
 
 function restoreRoom(_room) {
@@ -516,6 +601,18 @@ onUnmounted(() => {
 	100% { translate: -130px; }
 }
 
+.transition_serif_enterActive,
+.transition_serif_leaveActive {
+	transition: opacity 0.1s linear, scale 0.1s linear;
+}
+.transition_serif_enterFrom {
+	scale: 1.25;
+	opacity: 0;
+}
+.transition_serif_leaveTo {
+	opacity: 0;
+}
+
 .root {
 	background: #3C7A43;
 	background-image: url('/client-assets/mahjong/bg.jpg');
@@ -535,6 +632,22 @@ onUnmounted(() => {
 	box-sizing: border-box;
 }
 
+.kyokuResult {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 1000;
+	width: 100%;
+	height: 100%;
+	max-width: 800px;
+	margin: auto;
+	box-sizing: border-box;
+	background: #0009;
+	color: #fff;
+}
+
 .centerPanel {
 	position: absolute;
 	top: 0;
@@ -634,6 +747,24 @@ onUnmounted(() => {
 	right: 80px;
 }
 
+.huroTilesOfToimen {
+	position: absolute;
+	top: 0;
+	left: 80px;
+}
+
+.huroTilesOfKamitya {
+	position: absolute;
+	top: 80px;
+	left: 0;
+}
+
+.huroTilesOfSimotya {
+	position: absolute;
+	top: 80px;
+	right: 0;
+}
+
 .hoTilesContainer {
 	position: absolute;
 	top: 0;
@@ -766,6 +897,7 @@ onUnmounted(() => {
 	aspect-ratio: 0.7;
 	transition: translate 0.1s ease;
 	cursor: pointer;
+	overflow: clip;
 }
 .myTile:hover {
 	translate: 0 -10px;
diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts
index 27c2276ad4..d696dea0fb 100644
--- a/packages/misskey-mahjong/src/common.ts
+++ b/packages/misskey-mahjong/src/common.ts
@@ -158,7 +158,7 @@ type EnvForCalcYaku = {
 	house: House;
 
 	/**
-	 * 和了る人の手牌(副露した牌は含まない)
+	 * 和了る人の手牌(副露牌および和了る際のツモ牌・ロン牌は含まない)
 	 */
 	handTiles: Tile[];
 
@@ -214,6 +214,12 @@ export const YAKU_DEFINITIONS = [{
 	calc: (state: EnvForCalcYaku) => {
 		return state.riichi;
 	},
+}, {
+	name: 'tsumo',
+	fan: 1,
+	calc: (state: EnvForCalcYaku) => {
+		return state.tsumoTile != null;
+	},
 }, {
 	name: 'red',
 	fan: 1,
@@ -272,7 +278,7 @@ export const YAKU_DEFINITIONS = [{
 	calc: (state: EnvForCalcYaku) => {
 		const yaochuTiles: Tile[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
 		return (
-			(state.handTiles.filter(t => yaochuTiles.includes(t)).length === 0) &&
+			(!state.handTiles.some(t => yaochuTiles.includes(t))) &&
 			(state.huros.filter(huro =>
 				huro.type === 'pon' ? yaochuTiles.includes(huro.tile) :
 				huro.type === 'ankan' ? yaochuTiles.includes(huro.tile) :
@@ -281,6 +287,27 @@ export const YAKU_DEFINITIONS = [{
 				false).length === 0)
 		);
 	},
+}, {
+	name: 'pinfu',
+	fan: 1,
+	calc: (state: EnvForCalcYaku) => {
+		// 面前じゃないとダメ
+		if (state.huros.length !== 0) return false;
+		// 三元牌はダメ
+		if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false;
+
+		// TODO: 両面待ちかどうか
+
+		const horaSets = getHoraSets(state.handTiles.concat(state.tsumoTile ?? state.ronTile));
+		return horaSets.some(horaSet => {
+			// 風牌判定(役牌でなければOK)
+			if (horaSet.head === state.seatWind) return false;
+			if (horaSet.head === state.fieldWind) return false;
+
+			// 全て順子か?
+			if (horaSet.mentsus.some((mentsu) => mentsu[0] === mentsu[1])) return false;
+		});
+	},
 }];
 
 export function fanToPoint(fan: number, isParent: boolean): number {
@@ -423,29 +450,44 @@ export const SHUNTU_PATTERNS: [Tile, Tile, Tile][] = [
 	['s7', 's8', 's9'],
 ];
 
-const SHUNTU_PATTERN_IDS = [
-	'm123',
-	'm234',
-	'm345',
-	'm456',
-	'm567',
-	'm678',
-	'm789',
-	'p123',
-	'p234',
-	'p345',
-	'p456',
-	'p567',
-	'p678',
-	'p789',
-	's123',
-	's234',
-	's345',
-	's456',
-	's567',
-	's678',
-	's789',
-] as const;
+export type KyokuResult = {
+	yakus: { name: string; fan: number; }[];
+	doraCount: number;
+	pointDeltas: { e: number; s: number; w: number; n: number; };
+};
+
+function extractShuntsus(tiles: Tile[]): [Tile, Tile, Tile][] {
+	const tempTiles = [...tiles];
+
+	tempTiles.sort((a, b) => {
+		const aIndex = TILE_TYPES.indexOf(a);
+		const bIndex = TILE_TYPES.indexOf(b);
+		return aIndex - bIndex;
+	});
+
+	const shuntsus: [Tile, Tile, Tile][] = [];
+	while (tempTiles.length > 0) {
+		let isShuntu = false;
+		for (const shuntuPattern of SHUNTU_PATTERNS) {
+			if (
+				tempTiles[0] === shuntuPattern[0] &&
+				tempTiles.includes(shuntuPattern[1]) &&
+				tempTiles.includes(shuntuPattern[2])
+			) {
+				shuntsus.push(shuntuPattern);
+				tempTiles.splice(0, 1);
+				tempTiles.splice(tempTiles.indexOf(shuntuPattern[1]), 1);
+				tempTiles.splice(tempTiles.indexOf(shuntuPattern[2]), 1);
+				isShuntu = true;
+				break;
+			}
+		}
+
+		if (!isShuntu) tempTiles.splice(0, 1);
+	}
+
+	return shuntsus;
+}
 
 /**
  * アガリ形パターン一覧を取得
@@ -537,34 +579,7 @@ export function getHoraSets(handTiles: Tile[]): HoraSet[] {
 				tempHandTilesWithoutKotsu.splice(tempHandTilesWithoutKotsu.indexOf(kotsuTile), 1);
 			}
 
-			tempHandTilesWithoutKotsu.sort((a, b) => {
-				const aIndex = TILE_TYPES.indexOf(a);
-				const bIndex = TILE_TYPES.indexOf(b);
-				return aIndex - bIndex;
-			});
-
-			const tempHandTilesWithoutKotsuAndShuntsu: (Tile | null)[] = [...tempHandTilesWithoutKotsu];
-
-			const shuntsus: [Tile, Tile, Tile][] = [];
-			while (tempHandTilesWithoutKotsuAndShuntsu.length > 0) {
-				let isShuntu = false;
-				for (const shuntuPattern of SHUNTU_PATTERNS) {
-					if (
-						tempHandTilesWithoutKotsuAndShuntsu[0] === shuntuPattern[0] &&
-						tempHandTilesWithoutKotsuAndShuntsu.includes(shuntuPattern[1]) &&
-						tempHandTilesWithoutKotsuAndShuntsu.includes(shuntuPattern[2])
-					) {
-						shuntsus.push(shuntuPattern);
-						tempHandTilesWithoutKotsuAndShuntsu.splice(0, 1);
-						tempHandTilesWithoutKotsuAndShuntsu.splice(tempHandTilesWithoutKotsuAndShuntsu.indexOf(shuntuPattern[1]), 1);
-						tempHandTilesWithoutKotsuAndShuntsu.splice(tempHandTilesWithoutKotsuAndShuntsu.indexOf(shuntuPattern[2]), 1);
-						isShuntu = true;
-						break;
-					}
-				}
-
-				if (!isShuntu) tempHandTilesWithoutKotsuAndShuntsu.splice(0, 1);
-			}
+			const shuntsus = extractShuntsus(tempHandTilesWithoutKotsu);
 
 			if (shuntsus.length * 3 === tempHandTilesWithoutKotsu.length) { // アガリ形
 				horaSets.push({
diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts
index 26707bfb65..452918bb08 100644
--- a/packages/misskey-mahjong/src/engine.master.ts
+++ b/packages/misskey-mahjong/src/engine.master.ts
@@ -380,6 +380,7 @@ export class MasterGameEngine {
 		return {
 			asking: false as const,
 			tsumoTile: tsumoTile,
+			next: this.state.turn,
 		};
 	}
 
@@ -439,6 +440,11 @@ export class MasterGameEngine {
 		console.log('yakus', house, yakus);
 
 		this.endKyoku();
+
+		return {
+			handTiles: this.state.handTiles[house],
+			tsumoTile: this.state.handTiles[house].at(-1)!,
+		};
 	}
 
 	public commit_resolveCallAndRonInterruption(answers: {
diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts
index 0f6d86b874..578555ffd4 100644
--- a/packages/misskey-mahjong/src/engine.player.ts
+++ b/packages/misskey-mahjong/src/engine.player.ts
@@ -64,6 +64,12 @@ export type PlayerState = {
 	canRonTo: House | null;
 };
 
+export type KyokuResult = {
+	yakus: { name: string; fan: number; }[];
+	doraCount: number;
+	pointDeltas: { e: number; s: number; w: number; n: number; };
+};
+
 export class PlayerGameEngine {
 	/**
 	 * このエラーが発生したときはdesyncが疑われる
@@ -145,10 +151,33 @@ export class PlayerGameEngine {
 		if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError();
 	}
 
-	public commit_tsumoHora(house: House) {
+	public commit_tsumoHora(house: House, handTiles: Tile[], tsumoTile: Tile): KyokuResult {
 		console.log('commit_tsumoHora', this.state.turn, house);
 
-	// TODO: ツモした人の手牌情報を貰う必要がある
+		const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
+			house: house,
+			handTiles: handTiles,
+			huros: this.state.huros[house],
+			tsumoTile: tsumoTile,
+			ronTile: null,
+			riichi: this.state.riichis[house],
+		}));
+		const doraCount = Common.calcOwnedDoraCount(handTiles, this.state.huros[house], this.doras);
+		const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
+		const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans);
+		this.state.points.e += pointDeltas.e;
+		this.state.points.s += pointDeltas.s;
+		this.state.points.w += pointDeltas.w;
+		this.state.points.n += pointDeltas.n;
+
+		return {
+			yakus: yakus.map(yaku => ({
+				name: yaku.name,
+				fan: yaku.fan,
+			})),
+			doraCount,
+			pointDeltas,
+		};
 	}
 
 	/**
@@ -161,23 +190,16 @@ export class PlayerGameEngine {
 		s: Tile[];
 		w: Tile[];
 		n: Tile[];
-	}) {
+	}): Record<House, KyokuResult | null> {
 		console.log('commit_ronHora', this.state.turn, callers, callee);
 
 		this.state.canRonSource = null;
 
-		const yakusMap: Record<House, { name: string; fan: number; }[]> = {
-			e: [] as { name: string; fan: number; }[],
-			s: [] as { name: string; fan: number; }[],
-			w: [] as { name: string; fan: number; }[],
-			n: [] as { name: string; fan: number; }[],
-		};
-
-		const doraCountsMap: Record<House, number> = {
-			e: 0,
-			s: 0,
-			w: 0,
-			n: 0,
+		const resultMap: Record<House, KyokuResult> = {
+			e: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
+			s: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
+			w: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
+			n: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
 		};
 
 		for (const house of callers) {
@@ -194,14 +216,17 @@ export class PlayerGameEngine {
 			const point = Common.fanToPoint(fans, house === 'e');
 			this.state.points[callee] -= point;
 			this.state.points[house] += point;
-			yakusMap[house] = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan }));
-			doraCountsMap[house] = doraCount;
-			console.log('yakus', house, yakus);
+			resultMap[house].yakus = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan }));
+			resultMap[house].doraCount = doraCount;
+			resultMap[house].pointDeltas[callee] = -point;
+			resultMap[house].pointDeltas[house] = point;
 		}
 
 		return {
-			yakusMap,
-			doraCountsMap,
+			e: callers.includes('e') ? resultMap.e : null,
+			s: callers.includes('s') ? resultMap.s : null,
+			w: callers.includes('w') ? resultMap.w : null,
+			n: callers.includes('n') ? resultMap.n : null,
 		};
 	}