From 161fd4afab323ca6bf491def473f84bb7557b481 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 7 Mar 2018 17:48:32 +0900
Subject: [PATCH] wip

---
 src/api/endpoints.ts                          |  20 ++
 src/api/endpoints/othello/games.ts            |  22 ++
 src/api/endpoints/othello/invitations.ts      |  11 +
 src/api/endpoints/othello/match.ts            |  22 +-
 src/api/endpoints/othello/match/cancel.ts     |   9 +
 src/api/event.ts                              |   6 +
 src/api/models/othello-game.ts                |  20 +-
 src/api/models/othello-matching.ts            |  31 +++
 src/api/stream/othello-game.ts                |  63 +++++-
 .../{othello-matching.ts => othello.ts}       |   6 +-
 src/api/streaming.ts                          |   4 +-
 src/common/othello.ts                         |  66 ++++--
 src/web/app/common/mios.ts                    |  16 +-
 .../{channel-stream.ts => channel.ts}         |   0
 .../scripts/streaming/drive-stream-manager.ts |  20 --
 .../common/scripts/streaming/drive-stream.ts  |  12 --
 src/web/app/common/scripts/streaming/drive.ts |  31 +++
 .../scripts/streaming/home-stream-manager.ts  |  23 --
 .../streaming/{home-stream.ts => home.ts}     |  23 +-
 .../messaging-index-stream-manager.ts         |  20 --
 .../streaming/messaging-index-stream.ts       |  12 --
 .../scripts/streaming/messaging-index.ts      |  31 +++
 .../{messaging-stream.ts => messaging.ts}     |   2 +-
 .../common/scripts/streaming/othello-game.ts  |  10 +
 .../app/common/scripts/streaming/othello.ts   |  28 +++
 .../streaming/requests-stream-manager.ts      |  12 --
 .../scripts/streaming/requests-stream.ts      |  10 -
 .../app/common/scripts/streaming/requests.ts  |  21 ++
 .../streaming/server-stream-manager.ts        |  12 --
 .../common/scripts/streaming/server-stream.ts |  10 -
 .../app/common/scripts/streaming/server.ts    |  21 ++
 .../views/components/messaging-room.vue       |   4 +-
 .../common/views/components/othello.game.vue  | 120 ++++++++++-
 .../app/common/views/components/othello.vue   | 203 ++++++++++++++++--
 .../desktop/views/components/game-window.vue  |  24 +++
 .../views/components/ui.header.nav.vue        |  19 +-
 .../desktop/views/widgets/channel.channel.vue |   2 +-
 37 files changed, 747 insertions(+), 219 deletions(-)
 create mode 100644 src/api/endpoints/othello/games.ts
 create mode 100644 src/api/endpoints/othello/invitations.ts
 create mode 100644 src/api/endpoints/othello/match/cancel.ts
 rename src/api/stream/{othello-matching.ts => othello.ts} (62%)
 rename src/web/app/common/scripts/streaming/{channel-stream.ts => channel.ts} (100%)
 delete mode 100644 src/web/app/common/scripts/streaming/drive-stream-manager.ts
 delete mode 100644 src/web/app/common/scripts/streaming/drive-stream.ts
 create mode 100644 src/web/app/common/scripts/streaming/drive.ts
 delete mode 100644 src/web/app/common/scripts/streaming/home-stream-manager.ts
 rename src/web/app/common/scripts/streaming/{home-stream.ts => home.ts} (66%)
 delete mode 100644 src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts
 delete mode 100644 src/web/app/common/scripts/streaming/messaging-index-stream.ts
 create mode 100644 src/web/app/common/scripts/streaming/messaging-index.ts
 rename src/web/app/common/scripts/streaming/{messaging-stream.ts => messaging.ts} (83%)
 create mode 100644 src/web/app/common/scripts/streaming/othello-game.ts
 create mode 100644 src/web/app/common/scripts/streaming/othello.ts
 delete mode 100644 src/web/app/common/scripts/streaming/requests-stream-manager.ts
 delete mode 100644 src/web/app/common/scripts/streaming/requests-stream.ts
 create mode 100644 src/web/app/common/scripts/streaming/requests.ts
 delete mode 100644 src/web/app/common/scripts/streaming/server-stream-manager.ts
 delete mode 100644 src/web/app/common/scripts/streaming/server-stream.ts
 create mode 100644 src/web/app/common/scripts/streaming/server.ts
 create mode 100644 src/web/app/desktop/views/components/game-window.vue

diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index cbc016f20f..fad6667111 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -233,6 +233,26 @@ const endpoints: Endpoint[] = [
 		kind: 'notification-read'
 	},
 
+	{
+		name: 'othello/match',
+		withCredential: true
+	},
+
+	{
+		name: 'othello/match/cancel',
+		withCredential: true
+	},
+
+	{
+		name: 'othello/invitations',
+		withCredential: true
+	},
+
+	{
+		name: 'othello/games',
+		withCredential: true
+	},
+
 	{
 		name: 'mute/create',
 		withCredential: true,
diff --git a/src/api/endpoints/othello/games.ts b/src/api/endpoints/othello/games.ts
new file mode 100644
index 0000000000..62388b01d4
--- /dev/null
+++ b/src/api/endpoints/othello/games.ts
@@ -0,0 +1,22 @@
+import $ from 'cafy';
+import Game, { pack } from '../../models/othello-game';
+
+module.exports = (params, user) => new Promise(async (res, rej) => {
+	// Get 'my' parameter
+	const [my = false, myErr] = $(params.my).boolean().$;
+	if (myErr) return rej('invalid my param');
+
+	const q = my ? {
+		$or: [{
+			black_user_id: user._id
+		}, {
+			white_user_id: user._id
+		}]
+	} : {};
+
+	// Fetch games
+	const games = await Game.find(q);
+
+	// Reponse
+	res(Promise.all(games.map(async (g) => await pack(g, user))));
+});
diff --git a/src/api/endpoints/othello/invitations.ts b/src/api/endpoints/othello/invitations.ts
new file mode 100644
index 0000000000..f462ef0bf9
--- /dev/null
+++ b/src/api/endpoints/othello/invitations.ts
@@ -0,0 +1,11 @@
+import Matching, { pack as packMatching } from '../../models/othello-matching';
+
+module.exports = (params, user) => new Promise(async (res, rej) => {
+	// Find session
+	const invitations = await Matching.find({
+		child_id: user._id
+	});
+
+	// Reponse
+	res(Promise.all(invitations.map(async (i) => await packMatching(i, user))));
+});
diff --git a/src/api/endpoints/othello/match.ts b/src/api/endpoints/othello/match.ts
index 2dc22d11f9..65243a5571 100644
--- a/src/api/endpoints/othello/match.ts
+++ b/src/api/endpoints/othello/match.ts
@@ -1,6 +1,6 @@
 import $ from 'cafy';
-import Matching from '../../models/othello-matchig';
-import Game, { pack } from '../../models/othello-game';
+import Matching, { pack as packMatching } from '../../models/othello-matching';
+import Game, { pack as packGame } from '../../models/othello-game';
 import User from '../../models/user';
 import { publishOthelloStream } from '../../event';
 
@@ -33,17 +33,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 			created_at: new Date(),
 			black_user_id: parentIsBlack ? exist.parent_id : user._id,
 			white_user_id: parentIsBlack ? user._id : exist.parent_id,
+			turn_user_id: parentIsBlack ? exist.parent_id : user._id,
 			logs: []
 		});
 
-		const packedGame = await pack(game);
-
 		// Reponse
-		res(packedGame);
+		res(await packGame(game, user));
 
-		publishOthelloStream(exist.parent_id, 'matched', {
-			game
-		});
+		publishOthelloStream(exist.parent_id, 'matched', await packGame(game, exist.parent_id));
 	} else {
 		// Fetch child
 		const child = await User.findOne({
@@ -64,17 +61,16 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 		});
 
 		// セッションを作成
-		await Matching.insert({
+		const matching = await Matching.insert({
+			created_at: new Date(),
 			parent_id: user._id,
 			child_id: child._id
 		});
 
 		// Reponse
-		res(204);
+		res();
 
 		// 招待
-		publishOthelloStream(child._id, 'invited', {
-			user_id: user._id
-		});
+		publishOthelloStream(child._id, 'invited', await packMatching(matching, child));
 	}
 });
diff --git a/src/api/endpoints/othello/match/cancel.ts b/src/api/endpoints/othello/match/cancel.ts
new file mode 100644
index 0000000000..6f751ef835
--- /dev/null
+++ b/src/api/endpoints/othello/match/cancel.ts
@@ -0,0 +1,9 @@
+import Matching from '../../../models/othello-matching';
+
+module.exports = (params, user) => new Promise(async (res, rej) => {
+	await Matching.remove({
+		parent_id: user._id
+	});
+
+	res();
+});
diff --git a/src/api/event.ts b/src/api/event.ts
index e68082f0a9..4c9cc18e4d 100644
--- a/src/api/event.ts
+++ b/src/api/event.ts
@@ -42,6 +42,10 @@ class MisskeyEvent {
 		this.publish(`othello-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	public publishOthelloGameStream(gameId: ID, type: string, value?: any): void {
+		this.publish(`othello-game-stream:${gameId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	public publishChannelStream(channelId: ID, type: string, value?: any): void {
 		this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value);
 	}
@@ -71,4 +75,6 @@ export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(e
 
 export const publishOthelloStream = ev.publishOthelloStream.bind(ev);
 
+export const publishOthelloGameStream = ev.publishOthelloGameStream.bind(ev);
+
 export const publishChannelStream = ev.publishChannelStream.bind(ev);
diff --git a/src/api/models/othello-game.ts b/src/api/models/othello-game.ts
index a6beaaf9c7..b9fd94ebc0 100644
--- a/src/api/models/othello-game.ts
+++ b/src/api/models/othello-game.ts
@@ -1,6 +1,7 @@
 import * as mongo from 'mongodb';
 import deepcopy = require('deepcopy');
 import db from '../../db/mongodb';
+import { IUser, pack as packUser } from './user';
 
 const Game = db.get<IGame>('othello_games');
 export default Game;
@@ -15,19 +16,30 @@ export interface IGame {
 
 /**
  * Pack an othello game for API response
- *
- * @param {any} game
- * @return {Promise<any>}
  */
 export const pack = (
-	game: any
+	game: any,
+	me?: string | mongo.ObjectID | IUser
 ) => new Promise<any>(async (resolve, reject) => {
 
+	// Me
+	const meId: mongo.ObjectID = me
+		? mongo.ObjectID.prototype.isPrototypeOf(me)
+			? me as mongo.ObjectID
+			: typeof me === 'string'
+				? new mongo.ObjectID(me)
+				: (me as IUser)._id
+		: null;
+
 	const _game = deepcopy(game);
 
 	// Rename _id to id
 	_game.id = _game._id;
 	delete _game._id;
 
+	// Populate user
+	_game.black_user = await packUser(_game.black_user_id, meId);
+	_game.white_user = await packUser(_game.white_user_id, meId);
+
 	resolve(_game);
 });
diff --git a/src/api/models/othello-matching.ts b/src/api/models/othello-matching.ts
index bd7aeef3cf..89fcd6df6a 100644
--- a/src/api/models/othello-matching.ts
+++ b/src/api/models/othello-matching.ts
@@ -1,11 +1,42 @@
 import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
 import db from '../../db/mongodb';
+import { IUser, pack as packUser } from './user';
 
 const Matching = db.get<IMatching>('othello_matchings');
 export default Matching;
 
 export interface IMatching {
 	_id: mongo.ObjectID;
+	created_at: Date;
 	parent_id: mongo.ObjectID;
 	child_id: mongo.ObjectID;
 }
+
+/**
+ * Pack an othello matching for API response
+ */
+export const pack = (
+	matching: any,
+	me?: string | mongo.ObjectID | IUser
+) => new Promise<any>(async (resolve, reject) => {
+
+	// Me
+	const meId: mongo.ObjectID = me
+		? mongo.ObjectID.prototype.isPrototypeOf(me)
+			? me as mongo.ObjectID
+			: typeof me === 'string'
+				? new mongo.ObjectID(me)
+				: (me as IUser)._id
+		: null;
+
+	const _matching = deepcopy(matching);
+
+	delete _matching._id;
+
+	// Populate user
+	_matching.parent = await packUser(_matching.parent_id, meId);
+	_matching.child = await packUser(_matching.child_id, meId);
+
+	resolve(_matching);
+});
diff --git a/src/api/stream/othello-game.ts b/src/api/stream/othello-game.ts
index ab91ef6422..17cdd3a9e7 100644
--- a/src/api/stream/othello-game.ts
+++ b/src/api/stream/othello-game.ts
@@ -1,12 +1,69 @@
 import * as websocket from 'websocket';
 import * as redis from 'redis';
+import Game from '../models/othello-game';
+import { publishOthelloGameStream } from '../event';
+import Othello from '../../common/othello';
 
-export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void {
-	const game = request.resourceURL.query.game;
+export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void {
+	const gameId = request.resourceURL.query.game;
 
 	// Subscribe game stream
-	subscriber.subscribe(`misskey:othello-game-stream:${game}`);
+	subscriber.subscribe(`misskey:othello-game-stream:${gameId}`);
 	subscriber.on('message', (_, data) => {
 		connection.send(data);
 	});
+
+	connection.on('message', async (data) => {
+		const msg = JSON.parse(data.utf8Data);
+
+		switch (msg.type) {
+			case 'set':
+				if (msg.pos == null) return;
+				const pos = msg.pos;
+
+				const game = await Game.findOne({ _id: gameId });
+
+				const o = new Othello();
+
+				game.logs.forEach(log => {
+					o.set(log.color, log.pos);
+				});
+
+				const myColor = game.black_user_id.equals(user._id) ? 'black' : 'white';
+				const opColor = myColor == 'black' ? 'white' : 'black';
+
+				if (!o.canReverse(myColor, pos)) return;
+				o.set(myColor, pos);
+
+				let turn;
+				if (o.getPattern(opColor).length > 0) {
+					turn = myColor == 'black' ? game.white_user_id : game.black_user_id;
+				} else {
+					turn = myColor == 'black' ? game.black_user_id : game.white_user_id;
+				}
+
+				const log = {
+					at: new Date(),
+					color: myColor,
+					pos
+				};
+
+				await Game.update({
+					_id: gameId
+				}, {
+					$set: {
+						turn_user_id: turn
+					},
+					$push: {
+						logs: log
+					}
+				});
+
+				publishOthelloGameStream(gameId, 'set', {
+					color: myColor,
+					pos
+				});
+				break;
+		}
+	});
 }
diff --git a/src/api/stream/othello-matching.ts b/src/api/stream/othello.ts
similarity index 62%
rename from src/api/stream/othello-matching.ts
rename to src/api/stream/othello.ts
index f30ce6eb0a..5056eb535c 100644
--- a/src/api/stream/othello-matching.ts
+++ b/src/api/stream/othello.ts
@@ -2,10 +2,8 @@ import * as websocket from 'websocket';
 import * as redis from 'redis';
 
 export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void {
-	const otherparty = request.resourceURL.query.otherparty;
-
-	// Subscribe matching stream
-	subscriber.subscribe(`misskey:othello-matching:${user._id}-${otherparty}`);
+	// Subscribe othello stream
+	subscriber.subscribe(`misskey:othello-stream:${user._id}`);
 	subscriber.on('message', (_, data) => {
 		connection.send(data);
 	});
diff --git a/src/api/streaming.ts b/src/api/streaming.ts
index 66c2e0cec0..7d67ba9574 100644
--- a/src/api/streaming.ts
+++ b/src/api/streaming.ts
@@ -11,7 +11,7 @@ import driveStream from './stream/drive';
 import messagingStream from './stream/messaging';
 import messagingIndexStream from './stream/messaging-index';
 import othelloGameStream from './stream/othello-game';
-import othelloMatchingStream from './stream/othello-matching';
+import othelloStream from './stream/othello';
 import serverStream from './stream/server';
 import requestsStream from './stream/requests';
 import channelStream from './stream/channel';
@@ -65,7 +65,7 @@ module.exports = (server: http.Server) => {
 			request.resourceURL.pathname === '/messaging' ? messagingStream :
 			request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream :
 			request.resourceURL.pathname === '/othello-game' ? othelloGameStream :
-			request.resourceURL.pathname === '/othello-matching' ? othelloMatchingStream :
+			request.resourceURL.pathname === '/othello' ? othelloStream :
 			null;
 
 		if (channel !== null) {
diff --git a/src/common/othello.ts b/src/common/othello.ts
index 858fc33158..fc27d72dcd 100644
--- a/src/common/othello.ts
+++ b/src/common/othello.ts
@@ -1,37 +1,38 @@
 const BOARD_SIZE = 8;
 
 export default class Othello {
-	public board: Array<Array<'black' | 'white'>>;
+	public board: Array<'black' | 'white'>;
 
 	/**
 	 * ゲームを初期化します
 	 */
 	constructor() {
 		this.board = [
-			[null, null, null, null, null, null, null, null],
-			[null, null, null, null, null, null, null, null],
-			[null, null, null, null, null, null, null, null],
-			[null, null, null, 'black', 'white', null, null, null],
-			[null, null, null, 'white', 'black', null, null, null],
-			[null, null, null, null, null, null, null, null],
-			[null, null, null, null, null, null, null, null],
-			[null, null, null, null, null, null, null, null]
+			null, null, null, null, null, null, null, null,
+			null, null, null, null, null, null, null, null,
+			null, null, null, null, null, null, null, null,
+			null, null, null, 'white', 'black', null, null, null,
+			null, null, null, 'black', 'white', null, null, null,
+			null, null, null, null, null, null, null, null,
+			null, null, null, null, null, null, null, null,
+			null, null, null, null, null, null, null, null
 		];
 	}
 
 	public setByNumber(color, n) {
 		const ps = this.getPattern(color);
-		this.set(color, ps[n][0], ps[n][1]);
+		this.set2(color, ps[n][0], ps[n][1]);
 	}
 
 	private write(color, x, y) {
-		this.board[y][x] = color;
+		const pos = x + (y * 8);
+		this.board[pos] = color;
 	}
 
 	/**
 	 * 石を配置します
 	 */
-	public set(color, x, y) {
+	public set2(color, x, y) {
 		this.write(color, x, y);
 
 		const reverses = this.getReverse(color, x, y);
@@ -89,24 +90,42 @@ export default class Othello {
 		});
 	}
 
+	public set(color, pos) {
+		const x = pos % BOARD_SIZE;
+		const y = Math.floor(pos / BOARD_SIZE);
+		this.set2(color, x, y);
+	}
+
+	public get(x, y) {
+		const pos = x + (y * 8);
+		return this.board[pos];
+	}
+
 	/**
 	 * 打つことができる場所を取得します
 	 */
 	public getPattern(myColor): number[][] {
 		const result = [];
-		this.board.forEach((stones, y) => stones.forEach((stone, x) => {
+		this.board.forEach((stone, i) => {
 			if (stone != null) return;
-			if (this.canReverse(myColor, x, y)) result.push([x, y]);
-		}));
+			const x = i % BOARD_SIZE;
+			const y = Math.floor(i / BOARD_SIZE);
+			if (this.canReverse2(myColor, x, y)) result.push([x, y]);
+		});
 		return result;
 	}
 
 	/**
 	 * 指定の位置に石を打つことができるかどうか(相手の石を1つでも反転させられるか)を取得します
 	 */
-	public canReverse(myColor, targetx, targety): boolean {
+	public canReverse2(myColor, targetx, targety): boolean {
 		return this.getReverse(myColor, targetx, targety) !== null;
 	}
+	public canReverse(myColor, pos): boolean {
+		const x = pos % BOARD_SIZE;
+		const y = Math.floor(pos / BOARD_SIZE);
+		return this.getReverse(myColor, x, y) !== null;
+	}
 
 	private getReverse(myColor, targetx, targety): number[] {
 		const opponentColor = myColor == 'black' ? 'white' : 'black';
@@ -117,11 +136,11 @@ export default class Othello {
 			return (x, y): any => {
 				if (breaked) {
 					return;
-				} else if (this.board[y][x] == myColor && opponentStoneFound) {
+				} else if (this.get(x, y) == myColor && opponentStoneFound) {
 					return true;
-				} else if (this.board[y][x] == myColor && !opponentStoneFound) {
+				} else if (this.get(x, y) == myColor && !opponentStoneFound) {
 					breaked = true;
-				} else if (this.board[y][x] == opponentColor) {
+				} else if (this.get(x, y) == opponentColor) {
 					opponentStoneFound = true;
 				} else {
 					breaked = true;
@@ -210,12 +229,13 @@ export default class Othello {
 
 	public toString(): string {
 		//return this.board.map(row => row.map(state => state === 'black' ? '●' : state === 'white' ? '○' : '┼').join('')).join('\n');
-		return this.board.map(row => row.map(state => state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : '🔹').join('')).join('\n');
+		//return this.board.map(row => row.map(state => state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : '🔹').join('')).join('\n');
+		return 'wip';
 	}
 
 	public toPatternString(color): string {
 		//const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
-		const num = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🍍'];
+		/*const num = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🍍'];
 
 		const pattern = this.getPattern(color);
 
@@ -223,7 +243,9 @@ export default class Othello {
 			const i = pattern.findIndex(p => p[0] == x && p[1] == y);
 			//return state === 'black' ? '●' : state === 'white' ? '○' : i != -1 ? num[i] : '┼';
 			return state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : i != -1 ? num[i] : '🔹';
-		}).join('')).join('\n');
+		}).join('')).join('\n');*/
+
+		return 'wip';
 	}
 }
 
diff --git a/src/web/app/common/mios.ts b/src/web/app/common/mios.ts
index cbb4400a73..3690d3171d 100644
--- a/src/web/app/common/mios.ts
+++ b/src/web/app/common/mios.ts
@@ -4,11 +4,12 @@ import * as merge from 'object-assign-deep';
 
 import { host, apiUrl, swPublickey, version, lang, googleMapsApiKey } from '../config';
 import Progress from './scripts/loading';
-import HomeStreamManager from './scripts/streaming/home-stream-manager';
-import DriveStreamManager from './scripts/streaming/drive-stream-manager';
-import ServerStreamManager from './scripts/streaming/server-stream-manager';
-import RequestsStreamManager from './scripts/streaming/requests-stream-manager';
-import MessagingIndexStreamManager from './scripts/streaming/messaging-index-stream-manager';
+import { HomeStreamManager } from './scripts/streaming/home';
+import { DriveStreamManager } from './scripts/streaming/drive';
+import { ServerStreamManager } from './scripts/streaming/server';
+import { RequestsStreamManager } from './scripts/streaming/requests';
+import { MessagingIndexStreamManager } from './scripts/streaming/messaging-index';
+import { OthelloStreamManager } from './scripts/streaming/othello';
 
 import Err from '../common/views/components/connect-failed.vue';
 
@@ -117,11 +118,13 @@ export default class MiOS extends EventEmitter {
 		serverStream: ServerStreamManager;
 		requestsStream: RequestsStreamManager;
 		messagingIndexStream: MessagingIndexStreamManager;
+		othelloStream: OthelloStreamManager;
 	} = {
 		driveStream: null,
 		serverStream: null,
 		requestsStream: null,
-		messagingIndexStream: null
+		messagingIndexStream: null,
+		othelloStream: null
 	};
 
 	/**
@@ -169,6 +172,7 @@ export default class MiOS extends EventEmitter {
 			// Init other stream manager
 			this.streams.driveStream = new DriveStreamManager(this.i);
 			this.streams.messagingIndexStream = new MessagingIndexStreamManager(this.i);
+			this.streams.othelloStream = new OthelloStreamManager(this.i);
 		});
 
 		if (this.debug) {
diff --git a/src/web/app/common/scripts/streaming/channel-stream.ts b/src/web/app/common/scripts/streaming/channel.ts
similarity index 100%
rename from src/web/app/common/scripts/streaming/channel-stream.ts
rename to src/web/app/common/scripts/streaming/channel.ts
diff --git a/src/web/app/common/scripts/streaming/drive-stream-manager.ts b/src/web/app/common/scripts/streaming/drive-stream-manager.ts
deleted file mode 100644
index 8acdd7cbba..0000000000
--- a/src/web/app/common/scripts/streaming/drive-stream-manager.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import StreamManager from './stream-manager';
-import Connection from './drive-stream';
-
-export default class DriveStreamManager extends StreamManager<Connection> {
-	private me;
-
-	constructor(me) {
-		super();
-
-		this.me = me;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new Connection(this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/drive-stream.ts b/src/web/app/common/scripts/streaming/drive-stream.ts
deleted file mode 100644
index 0da3f12554..0000000000
--- a/src/web/app/common/scripts/streaming/drive-stream.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import Stream from './stream';
-
-/**
- * Drive stream connection
- */
-export default class Connection extends Stream {
-	constructor(me) {
-		super('drive', {
-			i: me.token
-		});
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/drive.ts b/src/web/app/common/scripts/streaming/drive.ts
new file mode 100644
index 0000000000..5805e58033
--- /dev/null
+++ b/src/web/app/common/scripts/streaming/drive.ts
@@ -0,0 +1,31 @@
+import Stream from './stream';
+import StreamManager from './stream-manager';
+
+/**
+ * Drive stream connection
+ */
+export class DriveStream extends Stream {
+	constructor(me) {
+		super('drive', {
+			i: me.token
+		});
+	}
+}
+
+export class DriveStreamManager extends StreamManager<DriveStream> {
+	private me;
+
+	constructor(me) {
+		super();
+
+		this.me = me;
+	}
+
+	public getConnection() {
+		if (this.connection == null) {
+			this.connection = new DriveStream(this.me);
+		}
+
+		return this.connection;
+	}
+}
diff --git a/src/web/app/common/scripts/streaming/home-stream-manager.ts b/src/web/app/common/scripts/streaming/home-stream-manager.ts
deleted file mode 100644
index ab56d5a73a..0000000000
--- a/src/web/app/common/scripts/streaming/home-stream-manager.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import StreamManager from './stream-manager';
-import Connection from './home-stream';
-import MiOS from '../../mios';
-
-export default class HomeStreamManager extends StreamManager<Connection> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new Connection(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/home-stream.ts b/src/web/app/common/scripts/streaming/home.ts
similarity index 66%
rename from src/web/app/common/scripts/streaming/home-stream.ts
rename to src/web/app/common/scripts/streaming/home.ts
index 3516705e22..1f110bfd3b 100644
--- a/src/web/app/common/scripts/streaming/home-stream.ts
+++ b/src/web/app/common/scripts/streaming/home.ts
@@ -1,12 +1,13 @@
 import * as merge from 'object-assign-deep';
 
 import Stream from './stream';
+import StreamManager from './stream-manager';
 import MiOS from '../../mios';
 
 /**
  * Home stream connection
  */
-export default class Connection extends Stream {
+export class HomeStream extends Stream {
 	constructor(os: MiOS, me) {
 		super('', {
 			i: me.token
@@ -34,3 +35,23 @@ export default class Connection extends Stream {
 		});
 	}
 }
+
+export class HomeStreamManager extends StreamManager<HomeStream> {
+	private me;
+	private os: MiOS;
+
+	constructor(os: MiOS, me) {
+		super();
+
+		this.me = me;
+		this.os = os;
+	}
+
+	public getConnection() {
+		if (this.connection == null) {
+			this.connection = new HomeStream(this.os, this.me);
+		}
+
+		return this.connection;
+	}
+}
diff --git a/src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts b/src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts
deleted file mode 100644
index 0f08b01481..0000000000
--- a/src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import StreamManager from './stream-manager';
-import Connection from './messaging-index-stream';
-
-export default class MessagingIndexStreamManager extends StreamManager<Connection> {
-	private me;
-
-	constructor(me) {
-		super();
-
-		this.me = me;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new Connection(this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/messaging-index-stream.ts b/src/web/app/common/scripts/streaming/messaging-index-stream.ts
deleted file mode 100644
index 8015c840b4..0000000000
--- a/src/web/app/common/scripts/streaming/messaging-index-stream.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import Stream from './stream';
-
-/**
- * Messaging index stream connection
- */
-export default class Connection extends Stream {
-	constructor(me) {
-		super('messaging-index', {
-			i: me.token
-		});
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/messaging-index.ts b/src/web/app/common/scripts/streaming/messaging-index.ts
new file mode 100644
index 0000000000..69758416dc
--- /dev/null
+++ b/src/web/app/common/scripts/streaming/messaging-index.ts
@@ -0,0 +1,31 @@
+import Stream from './stream';
+import StreamManager from './stream-manager';
+
+/**
+ * Messaging index stream connection
+ */
+export class MessagingIndexStream extends Stream {
+	constructor(me) {
+		super('messaging-index', {
+			i: me.token
+		});
+	}
+}
+
+export class MessagingIndexStreamManager extends StreamManager<MessagingIndexStream> {
+	private me;
+
+	constructor(me) {
+		super();
+
+		this.me = me;
+	}
+
+	public getConnection() {
+		if (this.connection == null) {
+			this.connection = new MessagingIndexStream(this.me);
+		}
+
+		return this.connection;
+	}
+}
diff --git a/src/web/app/common/scripts/streaming/messaging-stream.ts b/src/web/app/common/scripts/streaming/messaging.ts
similarity index 83%
rename from src/web/app/common/scripts/streaming/messaging-stream.ts
rename to src/web/app/common/scripts/streaming/messaging.ts
index 68dfc5ec09..1fff2286b3 100644
--- a/src/web/app/common/scripts/streaming/messaging-stream.ts
+++ b/src/web/app/common/scripts/streaming/messaging.ts
@@ -3,7 +3,7 @@ import Stream from './stream';
 /**
  * Messaging stream connection
  */
-export default class Connection extends Stream {
+export class MessagingStream extends Stream {
 	constructor(me, otherparty) {
 		super('messaging', {
 			i: me.token,
diff --git a/src/web/app/common/scripts/streaming/othello-game.ts b/src/web/app/common/scripts/streaming/othello-game.ts
new file mode 100644
index 0000000000..51a435541a
--- /dev/null
+++ b/src/web/app/common/scripts/streaming/othello-game.ts
@@ -0,0 +1,10 @@
+import Stream from './stream';
+
+export class OthelloGameStream extends Stream {
+	constructor(me, game) {
+		super('othello-game', {
+			i: me.token,
+			game: game.id
+		});
+	}
+}
diff --git a/src/web/app/common/scripts/streaming/othello.ts b/src/web/app/common/scripts/streaming/othello.ts
new file mode 100644
index 0000000000..febc5d498a
--- /dev/null
+++ b/src/web/app/common/scripts/streaming/othello.ts
@@ -0,0 +1,28 @@
+import StreamManager from './stream-manager';
+import Stream from './stream';
+
+export class OthelloStream extends Stream {
+	constructor(me) {
+		super('othello', {
+			i: me.token
+		});
+	}
+}
+
+export class OthelloStreamManager extends StreamManager<OthelloStream> {
+	private me;
+
+	constructor(me) {
+		super();
+
+		this.me = me;
+	}
+
+	public getConnection() {
+		if (this.connection == null) {
+			this.connection = new OthelloStream(this.me);
+		}
+
+		return this.connection;
+	}
+}
diff --git a/src/web/app/common/scripts/streaming/requests-stream-manager.ts b/src/web/app/common/scripts/streaming/requests-stream-manager.ts
deleted file mode 100644
index 44db913e78..0000000000
--- a/src/web/app/common/scripts/streaming/requests-stream-manager.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import StreamManager from './stream-manager';
-import Connection from './requests-stream';
-
-export default class RequestsStreamManager extends StreamManager<Connection> {
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new Connection();
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/requests-stream.ts b/src/web/app/common/scripts/streaming/requests-stream.ts
deleted file mode 100644
index 22ecea6c07..0000000000
--- a/src/web/app/common/scripts/streaming/requests-stream.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import Stream from './stream';
-
-/**
- * Requests stream connection
- */
-export default class Connection extends Stream {
-	constructor() {
-		super('requests');
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/requests.ts b/src/web/app/common/scripts/streaming/requests.ts
new file mode 100644
index 0000000000..5d199a0742
--- /dev/null
+++ b/src/web/app/common/scripts/streaming/requests.ts
@@ -0,0 +1,21 @@
+import Stream from './stream';
+import StreamManager from './stream-manager';
+
+/**
+ * Requests stream connection
+ */
+export class RequestsStream extends Stream {
+	constructor() {
+		super('requests');
+	}
+}
+
+export class RequestsStreamManager extends StreamManager<RequestsStream> {
+	public getConnection() {
+		if (this.connection == null) {
+			this.connection = new RequestsStream();
+		}
+
+		return this.connection;
+	}
+}
diff --git a/src/web/app/common/scripts/streaming/server-stream-manager.ts b/src/web/app/common/scripts/streaming/server-stream-manager.ts
deleted file mode 100644
index a170daebb9..0000000000
--- a/src/web/app/common/scripts/streaming/server-stream-manager.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import StreamManager from './stream-manager';
-import Connection from './server-stream';
-
-export default class ServerStreamManager extends StreamManager<Connection> {
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new Connection();
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/server-stream.ts b/src/web/app/common/scripts/streaming/server-stream.ts
deleted file mode 100644
index b9e0684465..0000000000
--- a/src/web/app/common/scripts/streaming/server-stream.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import Stream from './stream';
-
-/**
- * Server stream connection
- */
-export default class Connection extends Stream {
-	constructor() {
-		super('server');
-	}
-}
diff --git a/src/web/app/common/scripts/streaming/server.ts b/src/web/app/common/scripts/streaming/server.ts
new file mode 100644
index 0000000000..b12198d2fd
--- /dev/null
+++ b/src/web/app/common/scripts/streaming/server.ts
@@ -0,0 +1,21 @@
+import Stream from './stream';
+import StreamManager from './stream-manager';
+
+/**
+ * Server stream connection
+ */
+export class ServerStream extends Stream {
+	constructor() {
+		super('server');
+	}
+}
+
+export class ServerStreamManager extends StreamManager<ServerStream> {
+	public getConnection() {
+		if (this.connection == null) {
+			this.connection = new ServerStream();
+		}
+
+		return this.connection;
+	}
+}
diff --git a/src/web/app/common/views/components/messaging-room.vue b/src/web/app/common/views/components/messaging-room.vue
index 637fa9cd60..547e9494e5 100644
--- a/src/web/app/common/views/components/messaging-room.vue
+++ b/src/web/app/common/views/components/messaging-room.vue
@@ -26,7 +26,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import MessagingStreamConnection from '../../scripts/streaming/messaging-stream';
+import { MessagingStream } from '../../scripts/streaming/messaging';
 import XMessage from './messaging-room.message.vue';
 import XForm from './messaging-room.form.vue';
 import { url } from '../../../config';
@@ -66,7 +66,7 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		this.connection = new MessagingStreamConnection((this as any).os.i, this.user.id);
+		this.connection = new MessagingStream((this as any).os.i, this.user.id);
 
 		this.connection.on('message', this.onMessage);
 		this.connection.on('read', this.onRead);
diff --git a/src/web/app/common/views/components/othello.game.vue b/src/web/app/common/views/components/othello.game.vue
index 3d3ffb2c07..cb006a289b 100644
--- a/src/web/app/common/views/components/othello.game.vue
+++ b/src/web/app/common/views/components/othello.game.vue
@@ -1,29 +1,131 @@
 <template>
-<div>
-	<header>黒:{{ game.black_user.name }} 白:{{ game.white_user.name }}</header>
+<div class="root">
+	<header><b>{{ game.black_user.name }}</b>(黒) vs <b>{{ game.white_user.name }}</b>(白)</header>
+	<p class="turn">{{ turn ? 'あなたのターンです' : '相手のターンです' }}<mk-ellipsis v-if="!turn"/></p>
+	<div>
+		<div v-for="(stone, i) in o.board"
+			:class="{ empty: stone == null, myTurn: turn, can: o.canReverse(turn ? myColor : opColor, i) }"
+			@click="set(i)"
+		>
+			<img v-if="stone == 'black'" :src="`${game.black_user.avatar_url}?thumbnail&size=64`" alt="">
+			<img v-if="stone == 'white'" :src="`${game.white_user.avatar_url}?thumbnail&size=64`" alt="">
+		</div>
+	</div>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import { OthelloGameStream } from '../../scripts/streaming/othello-game';
+import Othello from '../../../../../common/othello';
+
 export default Vue.extend({
 	props: ['game'],
 	data() {
 		return {
-			game: null,
-			connection: null,
-			connectionId: null
+			o: new Othello(),
+			turn: null,
+			connection: null
 		};
 	},
-	mounted() {
-		this.connection = (this as any).os.streams.othelloGameStream.getConnection();
-		this.connectionId = (this as any).os.streams.othelloGameStream.use();
+	computed: {
+		myColor(): string {
+			return this.game.black_user_id == (this as any).os.i.id ? 'black' : 'white';
+		},
+		opColor(): string {
+			return this.myColor == 'black' ? 'white' : 'black';
+		}
+	},
+	created() {
+		this.game.logs.forEach(log => {
+			this.o.set(log.color, log.pos);
+		});
 
+		this.turn = this.game.turn_user_id == (this as any).os.i.id;
+	},
+	mounted() {
+		this.connection = new OthelloGameStream((this as any).os.i, this.game);
 		this.connection.on('set', this.onSet);
 	},
 	beforeDestroy() {
 		this.connection.off('set', this.onSet);
-		(this as any).streams.othelloGameStream.dispose(this.connectionId);
+		this.connection.close();
 	},
+	methods: {
+		set(pos) {
+			if (!this.turn) return;
+			if (!this.o.canReverse(this.myColor, pos)) return;
+			this.o.set(this.myColor, pos);
+			if (this.o.getPattern(this.opColor).length > 0) {
+				this.turn = !this.turn;
+			}
+			this.connection.send({
+				type: 'set',
+				pos
+			});
+			this.$forceUpdate();
+		},
+		onSet(x) {
+			this.o.set(x.color, x.pos);
+			if (this.o.getPattern(this.myColor).length > 0) {
+				this.turn = true;
+			}
+			this.$forceUpdate();
+		}
+	}
 });
 </script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.root
+	text-align center
+
+	> header
+		padding 8px
+		border-bottom dashed 1px #c4cdd4
+
+	> div
+		display grid
+		grid-template-rows repeat(8, 1fr)
+		grid-template-columns repeat(8, 1fr)
+		grid-gap 4px
+		width 300px
+		height 300px
+		margin 0 auto
+
+		> div
+			background transparent
+			border-radius 6px
+			overflow hidden
+
+			*
+				pointer-events none
+				user-select none
+
+			&.empty
+				border solid 2px #f5f5f5
+
+			&.empty.can
+				background #f5f5f5
+
+			&.empty.myTurn
+				border-color #eee
+
+				&.can
+					background #eee
+					cursor pointer
+
+					&:hover
+						border-color darken($theme-color, 10%)
+						background $theme-color
+
+					&:active
+						background darken($theme-color, 10%)
+
+			> img
+				display block
+				width 100%
+				height 100%
+</style>
diff --git a/src/web/app/common/views/components/othello.vue b/src/web/app/common/views/components/othello.vue
index f5abcfb103..f409f162f6 100644
--- a/src/web/app/common/views/components/othello.vue
+++ b/src/web/app/common/views/components/othello.vue
@@ -1,21 +1,53 @@
 <template>
-<div>
+<div class="mk-othello">
 	<div v-if="game">
 		<x-game :game="game"/>
 	</div>
-	<div v-if="matching">
+	<div class="matching" v-else-if="matching">
 		<h1><b>{{ matching.name }}</b>を待っています<mk-ellipsis/></h1>
+		<div class="cancel">
+			<el-button round @click="cancel">キャンセル</el-button>
+		</div>
 	</div>
-	<div v-else>
-		<h1>Misskey Othello</h1>
-		<p>他のMisskeyユーザーとオセロで対戦しよう。</p>
-		<button>フリーマッチ(準備中)</button>
-		<button @click="match">指名</button>
-		<section>
-			<h2>対局の招待があります:</h2>
+	<div class="index" v-else>
+		<h1>Misskey %fa:circle%thell%fa:circle R%</h1>
+		<p>他のMisskeyユーザーとオセロで対戦しよう</p>
+		<div class="play">
+			<el-button round>フリーマッチ(準備中)</el-button>
+			<el-button type="primary" round @click="match">指名</el-button>
+			<details>
+				<summary>遊び方</summary>
+				<div>
+					<p>オセロは、相手と交互に石をボードに置いてゆき、相手の石を挟んでひっくり返しながら、最終的に残った石が多い方が勝ちというボードゲームです。</p>
+					<dl>
+						<dt><b>フリーマッチ</b></dt>
+						<dd>ランダムなユーザーと対戦するモードです。</dd>
+						<dt><b>指名</b></dt>
+						<dd>指定したユーザーと対戦するモードです。</dd>
+					</dl>
+				</div>
+			</details>
+		</div>
+		<section v-if="invitations.length > 0">
+			<h2>対局の招待があります!:</h2>
+			<div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)">
+				<img :src="`${i.parent.avatar_url}?thumbnail&size=32`" alt="">
+				<span class="name"><b>{{ i.parent.name }}</b></span>
+				<span class="username">@{{ i.parent.username }}</span>
+				<mk-time :time="i.created_at"/>
+			</div>
+		</section>
+		<section v-if="myGames.length > 0">
+			<h2>自分の対局</h2>
+			<div class="game" v-for="g in myGames" tabindex="-1" @click="game = g">
+				<img :src="`${g.black_user.avatar_url}?thumbnail&size=32`" alt="">
+				<img :src="`${g.white_user.avatar_url}?thumbnail&size=32`" alt="">
+				<span><b>{{ g.black_user.name }}</b> vs <b>{{ g.white_user.name }}</b></span>
+				<span class="state">{{ g.winner ? '終了' : '進行中' }}</span>
+			</div>
 		</section>
 		<section>
-			<h2>過去の対局</h2>
+			<h2>みんなの対局</h2>
 		</section>
 	</div>
 </div>
@@ -35,7 +67,8 @@ export default Vue.extend({
 			games: [],
 			gamesFetching: true,
 			gamesMoreFetching: false,
-			matching: false,
+			myGames: [],
+			matching: null,
 			invitations: [],
 			connection: null,
 			connectionId: null
@@ -45,9 +78,15 @@ export default Vue.extend({
 		this.connection = (this as any).os.streams.othelloStream.getConnection();
 		this.connectionId = (this as any).os.streams.othelloStream.use();
 
-		this.connection.on('macthed', this.onMatched);
+		this.connection.on('matched', this.onMatched);
 		this.connection.on('invited', this.onInvited);
 
+		(this as any).api('othello/games', {
+			my: true
+		}).then(games => {
+			this.myGames = games;
+		});
+
 		(this as any).api('othello/games').then(games => {
 			this.games = games;
 			this.gamesFetching = false;
@@ -58,9 +97,9 @@ export default Vue.extend({
 		});
 	},
 	beforeDestroy() {
-		this.connection.off('macthed', this.onMatched);
+		this.connection.off('matched', this.onMatched);
 		this.connection.off('invited', this.onInvited);
-		(this as any).streams.othelloStream.dispose(this.connectionId);
+		(this as any).os.streams.othelloStream.dispose(this.connectionId);
 	},
 	methods: {
 		match() {
@@ -82,6 +121,19 @@ export default Vue.extend({
 				});
 			});
 		},
+		cancel() {
+			this.matching = null;
+			(this as any).api('othello/match/cancel');
+		},
+		accept(invitation) {
+			(this as any).api('othello/match', {
+				user_id: invitation.parent.id
+			}).then(game => {
+				if (game) {
+					this.game = game;
+				}
+			});
+		},
 		onMatched(game) {
 			this.game = game;
 		},
@@ -92,3 +144,126 @@ export default Vue.extend({
 });
 </script>
 
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mk-othello
+	color #677f84
+
+	> .matching
+		> h1
+			margin 0
+			padding 24px
+			font-size 20px
+			text-align center
+			font-weight normal
+
+		> .cancel
+			margin 0 auto
+			padding 24px 0 0 0
+			max-width 200px
+			text-align center
+			border-top dashed 1px #c4cdd4
+
+	> .index
+		> h1
+			margin 0
+			padding 24px
+			font-size 24px
+			text-align center
+			font-weight normal
+			color #fff
+			background linear-gradient(to bottom, #8bca3e, #d6cf31)
+
+			& + p
+				margin 0
+				padding 12px
+				margin-bottom 12px
+				text-align center
+				font-size 14px
+				border-bottom solid 1px #d3d9dc
+
+		> .play
+			margin 0 auto
+			padding 0 16px
+			max-width 500px
+			text-align center
+
+			> details
+				margin 8px 0
+
+				> div
+					padding 16px
+					font-size 14px
+					text-align left
+					background #f5f5f5
+					border-radius 8px
+
+		> section
+			margin 0 auto
+			padding 0 16px 16px 16px
+			max-width 500px
+			border-top solid 1px #d3d9dc
+
+			> h2
+				margin 0
+				padding 16px 0 8px 0
+				font-size 16px
+				font-weight bold
+
+	.invitation
+		margin 8px 0
+		padding 8px
+		border solid 1px #e1e5e8
+		border-radius 6px
+		cursor pointer
+
+		*
+			pointer-events none
+			user-select none
+
+		&:focus
+			border-color $theme-color
+
+		&:hover
+			background #f5f5f5
+
+		&:active
+			background #eee
+
+		> img
+			vertical-align bottom
+			border-radius 100%
+
+		> span
+			margin 0 8px
+			line-height 32px
+
+	.game
+		margin 8px 0
+		padding 8px
+		border solid 1px #e1e5e8
+		border-radius 6px
+		cursor pointer
+
+		*
+			pointer-events none
+			user-select none
+
+		&:focus
+			border-color $theme-color
+
+		&:hover
+			background #f5f5f5
+
+		&:active
+			background #eee
+
+		> img
+			vertical-align bottom
+			border-radius 100%
+
+		> span
+			margin 0 8px
+			line-height 32px
+</style>
diff --git a/src/web/app/desktop/views/components/game-window.vue b/src/web/app/desktop/views/components/game-window.vue
new file mode 100644
index 0000000000..bf339092aa
--- /dev/null
+++ b/src/web/app/desktop/views/components/game-window.vue
@@ -0,0 +1,24 @@
+<template>
+<mk-window ref="window" width="500px" height="560px" @closed="$destroy">
+	<span slot="header" :class="$style.header">%fa:gamepad%オセロ</span>
+	<mk-othello :class="$style.content"/>
+</mk-window>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+
+});
+</script>
+
+<style lang="stylus" module>
+.header
+	> [data-fa]
+		margin-right 4px
+
+.content
+	height 100%
+	overflow auto
+
+</style>
diff --git a/src/web/app/desktop/views/components/ui.header.nav.vue b/src/web/app/desktop/views/components/ui.header.nav.vue
index a5b6ecd6fa..54045db8d4 100644
--- a/src/web/app/desktop/views/components/ui.header.nav.vue
+++ b/src/web/app/desktop/views/components/ui.header.nav.vue
@@ -15,6 +15,13 @@
 					<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>
 				</a>
 			</li>
+			<li class="game">
+				<a @click="game">
+					%fa:gamepad%
+					<p>ゲーム</p>
+					<template v-if="hasGameInvitations">%fa:circle%</template>
+				</a>
+			</li>
 		</template>
 		<li class="ch">
 			<a :href="chUrl" target="_blank">
@@ -22,12 +29,6 @@
 				<p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p>
 			</a>
 		</li>
-		<li class="info">
-			<a href="https://twitter.com/misskey_xyz" target="_blank">
-				%fa:info%
-				<p>%i18n:desktop.tags.mk-ui-header-nav.info%</p>
-			</a>
-		</li>
 	</ul>
 </div>
 </template>
@@ -36,11 +37,13 @@
 import Vue from 'vue';
 import { chUrl } from '../../../config';
 import MkMessagingWindow from './messaging-window.vue';
+import MkGameWindow from './game-window.vue';
 
 export default Vue.extend({
 	data() {
 		return {
 			hasUnreadMessagingMessages: false,
+			hasGameInvitations: false,
 			connection: null,
 			connectionId: null,
 			chUrl
@@ -80,6 +83,10 @@ export default Vue.extend({
 
 		messaging() {
 			(this as any).os.new(MkMessagingWindow);
+		},
+
+		game() {
+			(this as any).os.new(MkGameWindow);
 		}
 	}
 });
diff --git a/src/web/app/desktop/views/widgets/channel.channel.vue b/src/web/app/desktop/views/widgets/channel.channel.vue
index 70dac316cf..02cdf6de13 100644
--- a/src/web/app/desktop/views/widgets/channel.channel.vue
+++ b/src/web/app/desktop/views/widgets/channel.channel.vue
@@ -11,7 +11,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import ChannelStream from '../../../common/scripts/streaming/channel-stream';
+import ChannelStream from '../../../common/scripts/streaming/channel';
 import XForm from './channel.channel.form.vue';
 import XPost from './channel.channel.post.vue';